From ef1dd60b715f8b71ff7eaf1ad91a5a143009b6df Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 19:22:23 -0700 Subject: [PATCH 01/38] adds Task model --- app/models/task.py | 5 +- app/routes.py | 8 +- migrations/README | 1 + migrations/alembic.ini | 45 +++++++++ migrations/env.py | 96 +++++++++++++++++++ migrations/script.py.mako | 24 +++++ .../8b3a58fadaf6_creates_task_model.py | 39 ++++++++ 7 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/8b3a58fadaf6_creates_task_model.py diff --git a/app/models/task.py b/app/models/task.py index 39c89cd16..60089ab9a 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -3,4 +3,7 @@ class Task(db.Model): - task_id = db.Column(db.Integer, primary_key=True) + task_id = db.Column(db.Integer, primary_key=True, autoincrement=True) + title = db.Column(db.String) + description = db.Column(db.String) + completed_at = db.Column(db.DateTime) \ No newline at end of file diff --git a/app/routes.py b/app/routes.py index 8e9dfe684..f10b13257 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,2 +1,8 @@ -from flask import Blueprint +from app import db +from app.models.task import Task +from flask import Blueprint, make_response, request, jsonify + +# tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks") + + diff --git a/migrations/README b/migrations/README new file mode 100644 index 000000000..98e4f9c44 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 000000000..f8ed4801f --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,45 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 000000000..8b3fb3353 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,96 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 000000000..2c0156303 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/8b3a58fadaf6_creates_task_model.py b/migrations/versions/8b3a58fadaf6_creates_task_model.py new file mode 100644 index 000000000..ce946fcb9 --- /dev/null +++ b/migrations/versions/8b3a58fadaf6_creates_task_model.py @@ -0,0 +1,39 @@ +"""creates Task model + +Revision ID: 8b3a58fadaf6 +Revises: +Create Date: 2021-05-05 19:19:11.083147 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8b3a58fadaf6' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('goal', + sa.Column('goal_id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('goal_id') + ) + op.create_table('task', + sa.Column('task_id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(), nullable=True), + sa.Column('description', sa.String(), nullable=True), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('task_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('task') + op.drop_table('goal') + # ### end Alembic commands ### From b7244896e7337d95442c4928231f32ae74865564 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 19:23:43 -0700 Subject: [PATCH 02/38] creates task_bp and registers blueprint --- app/__init__.py | 2 ++ app/routes.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/__init__.py b/app/__init__.py index 2764c4cc8..30052751d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -30,5 +30,7 @@ def create_app(test_config=None): migrate.init_app(app, db) # Register Blueprints here + from .routes import tasks_bp + app.register_blueprint(tasks_bp) return app diff --git a/app/routes.py b/app/routes.py index f10b13257..030854dc0 100644 --- a/app/routes.py +++ b/app/routes.py @@ -2,7 +2,7 @@ from app.models.task import Task from flask import Blueprint, make_response, request, jsonify -# tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks") +tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks") From b7a3f5a31b9aa60ff47f64f80be0145d38b2ec41 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 19:36:44 -0700 Subject: [PATCH 03/38] creates /tasks endpoint that accepts POST method --- app/routes.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/routes.py b/app/routes.py index 030854dc0..ac3a76ca6 100644 --- a/app/routes.py +++ b/app/routes.py @@ -4,5 +4,25 @@ tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks") +@tasks_bp.route("", methods=["POST"]) +def handle_tasks(): + request_body = request.get_json() + new_task = Task(title=request_body['title'], + description=request_body['description'], + completed_at=request_body['completed_at']) + + db.session.add(new_task) + db.session.commit() + + response = { + "task": { + "id": new_task.task_id, + "title": new_task.title, + "description": new_task.description, + "is_complete": False + } + } + + return make_response(jsonify(response), 201) \ No newline at end of file From 0b7d59ea07684f1fe47f058a931b0473b3d147dd Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 19:42:41 -0700 Subject: [PATCH 04/38] extends /tasks endpoint to accommodate GET method --- app/routes.py | 52 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/app/routes.py b/app/routes.py index ac3a76ca6..f79fdc711 100644 --- a/app/routes.py +++ b/app/routes.py @@ -4,25 +4,45 @@ tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks") -@tasks_bp.route("", methods=["POST"]) +@tasks_bp.route("", methods=["POST", "GET"]) def handle_tasks(): + if request.method == "POST": + request_body = request.get_json() - request_body = request.get_json() + new_task = Task(title=request_body['title'], + description=request_body['description'], + completed_at=request_body['completed_at']) - new_task = Task(title=request_body['title'], - description=request_body['description'], - completed_at=request_body['completed_at']) + db.session.add(new_task) + db.session.commit() - db.session.add(new_task) - db.session.commit() - - response = { - "task": { - "id": new_task.task_id, - "title": new_task.title, - "description": new_task.description, - "is_complete": False + response = { + "task": { + "id": new_task.task_id, + "title": new_task.title, + "description": new_task.description, + "is_complete": False + } } - } - return make_response(jsonify(response), 201) \ No newline at end of file + return make_response(jsonify(response), 201) + + elif request.method == "GET": + + tasks = Task.query.all() + tasks_response = [] + + for task in tasks: + tasks_response.append({ + "id": task.task_id, + "title": task.title, + "descroption":task.description, + "is_complete": is_task_complete(task) + }) + + return jsonify(tasks_response) + +def is_task_complete(task): + if not task.completed_at: + return False + return True \ No newline at end of file From 07fd37633f6335098a1c4faa56b2153212ed87eb Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 19:48:15 -0700 Subject: [PATCH 05/38] creates /tasks/ endpoint that accommodates GET method --- app/routes.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/routes.py b/app/routes.py index f79fdc711..635659cd7 100644 --- a/app/routes.py +++ b/app/routes.py @@ -21,7 +21,7 @@ def handle_tasks(): "id": new_task.task_id, "title": new_task.title, "description": new_task.description, - "is_complete": False + "is_complete": is_task_complete(new_task) } } @@ -42,6 +42,21 @@ def handle_tasks(): return jsonify(tasks_response) +@tasks_bp.route("/", methods=["GET"]) +def handle_task(task_id): + + task = Task.query.get(task_id) + + return { + "task": { + "id": task.task_id, + "title": task.title, + "description": task.description, + "is_complete": is_task_complete(task) + } + } + + def is_task_complete(task): if not task.completed_at: return False From 91931bd491aa19049a457eb9e8ea0d54efddc481 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 19:49:37 -0700 Subject: [PATCH 06/38] creates a 404 response if no matching task at /tasks/ endpoint --- app/routes.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/routes.py b/app/routes.py index 635659cd7..b5cc2f61d 100644 --- a/app/routes.py +++ b/app/routes.py @@ -46,6 +46,9 @@ def handle_tasks(): def handle_task(task_id): task = Task.query.get(task_id) + + if task is None: + return make_response("", 404) return { "task": { From e6a381624f23cc5665233f31caf4f5b6da71d48b Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 19:58:11 -0700 Subject: [PATCH 07/38] extends /tasks/ to accommodate PUT method --- app/routes.py | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/app/routes.py b/app/routes.py index b5cc2f61d..9f040eb6d 100644 --- a/app/routes.py +++ b/app/routes.py @@ -42,24 +42,46 @@ def handle_tasks(): return jsonify(tasks_response) -@tasks_bp.route("/", methods=["GET"]) +@tasks_bp.route("/", methods=["GET", "PUT"]) def handle_task(task_id): task = Task.query.get(task_id) if task is None: return make_response("", 404) + + if request.method == "GET": - return { - "task": { - "id": task.task_id, - "title": task.title, - "description": task.description, - "is_complete": is_task_complete(task) + return { + "task": { + "id": task.task_id, + "title": task.title, + "description": task.description, + "is_complete": is_task_complete(task) + } + } + + elif request.method == "PUT": + + request_body = request.get_json() + + task.title = request_body['title'] + task.description = request_body['description'] + task.completed_at = request_body['completed_at'] + + db.session.commit() + + return { + "task": { + "id": task.task_id, + "title": task.title, + "description": task.description, + "is_complete": is_task_complete(task) + } } - } +# Helper functions def is_task_complete(task): if not task.completed_at: return False From a7b311ed5a5328b43fa7d49b358012600bbfd1b6 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 20:02:20 -0700 Subject: [PATCH 08/38] extends /tasks/ endpoint to accommodate DELETE method --- app/routes.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/routes.py b/app/routes.py index 9f040eb6d..8c32b53f2 100644 --- a/app/routes.py +++ b/app/routes.py @@ -42,7 +42,7 @@ def handle_tasks(): return jsonify(tasks_response) -@tasks_bp.route("/", methods=["GET", "PUT"]) +@tasks_bp.route("/", methods=["GET", "PUT", "DELETE"]) def handle_task(task_id): task = Task.query.get(task_id) @@ -80,6 +80,15 @@ def handle_task(task_id): } } + elif request.method == "DELETE": + + db.session.delete(task) + db.session.commit() + + return { + "details": f"Task {task.task_id} \"{task.title}\" successfully deleted" + } + # Helper functions def is_task_complete(task): From fbc87ef40db93e09a135df4ac16954138e96dc66 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 20:12:06 -0700 Subject: [PATCH 09/38] returns 400 response if POST to /tasks endpoint contains missing data --- app/routes.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/routes.py b/app/routes.py index 8c32b53f2..af6ad76a5 100644 --- a/app/routes.py +++ b/app/routes.py @@ -9,9 +9,14 @@ def handle_tasks(): if request.method == "POST": request_body = request.get_json() - new_task = Task(title=request_body['title'], - description=request_body['description'], - completed_at=request_body['completed_at']) + try: + new_task = Task(title=request_body['title'], + description=request_body['description'], + completed_at=request_body['completed_at']) + except KeyError: + return make_response({ + "details": "Invalid data" + }, 400) db.session.add(new_task) db.session.commit() From 1ef0ca67f410bb797bc18649eb1d8c0a715af688 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 20:19:16 -0700 Subject: [PATCH 10/38] fixes typo to pass all wave 1 tests --- app/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes.py b/app/routes.py index af6ad76a5..d37ce3d7f 100644 --- a/app/routes.py +++ b/app/routes.py @@ -41,7 +41,7 @@ def handle_tasks(): tasks_response.append({ "id": task.task_id, "title": task.title, - "descroption":task.description, + "description":task.description, "is_complete": is_task_complete(task) }) From b0449e45a85addf497406000b6139cd007f16471 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 21:28:08 -0700 Subject: [PATCH 11/38] accommodates sort query param for GET method to /tasks endpoint --- app/routes.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/routes.py b/app/routes.py index d37ce3d7f..9aa304334 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,6 +1,7 @@ from app import db from app.models.task import Task from flask import Blueprint, make_response, request, jsonify +from sqlalchemy import desc tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks") @@ -34,7 +35,15 @@ def handle_tasks(): elif request.method == "GET": - tasks = Task.query.all() + sort_query = request.args.get("sort") + + if sort_query == "asc": + tasks = Task.query.order_by("title") + elif sort_query == "desc": + tasks = Task.query.order_by(desc("title")) + else: + tasks = Task.query.all() + tasks_response = [] for task in tasks: From 69ce6de9596ace4396287175c622ea9e6a882dba Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 22:08:13 -0700 Subject: [PATCH 12/38] adds routes for mark_incomplete and mark_complete and updates dependencies to include datetime, test wave 3 passing --- app/routes.py | 36 ++++++++++++++++++++++++++++++++++++ requirements.txt | 3 +++ 2 files changed, 39 insertions(+) diff --git a/app/routes.py b/app/routes.py index 9aa304334..84e22644c 100644 --- a/app/routes.py +++ b/app/routes.py @@ -2,6 +2,7 @@ from app.models.task import Task from flask import Blueprint, make_response, request, jsonify from sqlalchemy import desc +from datetime import datetime tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks") @@ -103,7 +104,42 @@ def handle_task(task_id): "details": f"Task {task.task_id} \"{task.title}\" successfully deleted" } +@tasks_bp.route("//mark_incomplete", methods=["PATCH"]) +def mark_task_incomplete(task_id): + task = Task.query.get(task_id) + + if task is None: + return make_response("", 404) + + task.completed_at = None + + return { + "task": { + "id": task.task_id, + "title": task.title, + "description": task.description, + "is_complete": is_task_complete(task) # False + } + } + +@tasks_bp.route("//mark_complete", methods=["PATCH"]) +def mark_task_complete(task_id): + task = Task.query.get(task_id) + + if task is None: + return make_response("", 404) + + task.completed_at = datetime.now() + + return { + "task": { + "id": task.task_id, + "title": task.title, + "description": task.description, + "is_complete": is_task_complete(task) # False + } + } # Helper functions def is_task_complete(task): if not task.completed_at: diff --git a/requirements.txt b/requirements.txt index cfdf74050..fb77cd1b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ autopep8==1.5.5 certifi==2020.12.5 chardet==4.0.0 click==7.1.2 +DateTime==4.3 Flask==1.1.2 Flask-Migrate==2.6.0 Flask-SQLAlchemy==2.4.4 @@ -24,9 +25,11 @@ pytest==6.2.3 python-dateutil==2.8.1 python-dotenv==0.15.0 python-editor==1.0.4 +pytz==2021.1 requests==2.25.1 six==1.15.0 SQLAlchemy==1.3.23 toml==0.10.2 urllib3==1.26.4 Werkzeug==1.0.1 +zope.interface==5.4.0 From 479ba4306eadffe1afe2aeb883644f7acd8a5d5d Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 23:28:17 -0700 Subject: [PATCH 13/38] refactors /tasks//mark_complete endpoint to send message to Slack API using slack_sdk package --- app/routes.py | 20 +++++++++++++++++++- requirements.txt | 1 + 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/routes.py b/app/routes.py index 84e22644c..8012e6fa1 100644 --- a/app/routes.py +++ b/app/routes.py @@ -3,6 +3,11 @@ from flask import Blueprint, make_response, request, jsonify from sqlalchemy import desc from datetime import datetime +import requests +import os +from dotenv import load_dotenv +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks") @@ -132,6 +137,8 @@ def mark_task_complete(task_id): task.completed_at = datetime.now() + post_to_slack(f"Someone just completed the task {task.title}") + return { "task": { "id": task.task_id, @@ -144,4 +151,15 @@ def mark_task_complete(task_id): def is_task_complete(task): if not task.completed_at: return False - return True \ No newline at end of file + return True + +def post_to_slack(message): + + client = WebClient(token=os.environ.get("SLACK_API_KEY")) + + client.chat_postMessage( + channel="task-notifications", + text=message + ) + + diff --git a/requirements.txt b/requirements.txt index fb77cd1b3..285323249 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,6 +28,7 @@ python-editor==1.0.4 pytz==2021.1 requests==2.25.1 six==1.15.0 +slack-sdk==3.5.1 SQLAlchemy==1.3.23 toml==0.10.2 urllib3==1.26.4 From f0cdea2dcd6c5f40c53fef95479c05ce6b045dff Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 23:36:13 -0700 Subject: [PATCH 14/38] refactors send_message_to_slack() function to use the requests package, per instructions, rather than slack_sdk package --- app/routes.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/app/routes.py b/app/routes.py index 8012e6fa1..7d4b8db89 100644 --- a/app/routes.py +++ b/app/routes.py @@ -6,8 +6,6 @@ import requests import os from dotenv import load_dotenv -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks") @@ -154,12 +152,21 @@ def is_task_complete(task): return True def post_to_slack(message): + """ + Posts a given message to the task-notifications channel in my Task Manager Slack workspace. + """ + path = "https://slack.com/api/chat.postMessage" - client = WebClient(token=os.environ.get("SLACK_API_KEY")) + SLACK_API_KEY = os.environ.get("SLACK_API_KEY") - client.chat_postMessage( - channel="task-notifications", - text=message - ) + auth_header = { + "Authorization": f"Bearer {SLACK_API_KEY}" + } + + query_params = { + "channel": "task-notifications", + "text": message + } + requests.post(path, params=query_params, headers=auth_header) From d45f4c3488da0349cc7c4521ebe6d434e1e991b1 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 23:51:57 -0700 Subject: [PATCH 15/38] creates Goal model, goals blueprint, and registers blueprint in __init__.py --- app/__init__.py | 5 ++-- app/models/goal.py | 1 + app/routes.py | 1 + .../versions/0572b382dd9e_adds_goal_model.py | 28 +++++++++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 migrations/versions/0572b382dd9e_adds_goal_model.py diff --git a/app/__init__.py b/app/__init__.py index 30052751d..2e7f34dd2 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -30,7 +30,8 @@ def create_app(test_config=None): migrate.init_app(app, db) # Register Blueprints here - from .routes import tasks_bp + from .routes import tasks_bp, goals_bp app.register_blueprint(tasks_bp) - + app.register_blueprint(goals_bp) + return app diff --git a/app/models/goal.py b/app/models/goal.py index 8cad278f8..0bdc1731d 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -4,3 +4,4 @@ class Goal(db.Model): goal_id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String) diff --git a/app/routes.py b/app/routes.py index 7d4b8db89..ca8b8f189 100644 --- a/app/routes.py +++ b/app/routes.py @@ -8,6 +8,7 @@ from dotenv import load_dotenv tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks") +goals_bp = Blueprint("goals", __name__, url_prefix="/goals") @tasks_bp.route("", methods=["POST", "GET"]) def handle_tasks(): diff --git a/migrations/versions/0572b382dd9e_adds_goal_model.py b/migrations/versions/0572b382dd9e_adds_goal_model.py new file mode 100644 index 000000000..5a317286e --- /dev/null +++ b/migrations/versions/0572b382dd9e_adds_goal_model.py @@ -0,0 +1,28 @@ +"""adds Goal model + +Revision ID: 0572b382dd9e +Revises: 8b3a58fadaf6 +Create Date: 2021-05-05 23:47:02.313925 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0572b382dd9e' +down_revision = '8b3a58fadaf6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('goal', sa.Column('title', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('goal', 'title') + # ### end Alembic commands ### From 1c515b5ff7ff280fc6cf59585c9664850d9f99bb Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 5 May 2021 23:57:27 -0700 Subject: [PATCH 16/38] creates /goals endpoint that accommodates POST method --- app/routes.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/routes.py b/app/routes.py index ca8b8f189..668224d06 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,5 +1,6 @@ from app import db from app.models.task import Task +from app.models.goal import Goal from flask import Blueprint, make_response, request, jsonify from sqlalchemy import desc from datetime import datetime @@ -146,6 +147,30 @@ def mark_task_complete(task_id): "is_complete": is_task_complete(task) # False } } + + +@goals_bp.route("", methods=['POST']) +def handle_goals(): + request_body = request.get_json() + + new_goal = Goal(title=request_body['title']) + + response = { + "goal": { + "id": new_goal.goal_id, + "title": new_goal.title + } + } + + return make_response(jsonify(response), 201) + + + + + + + + # Helper functions def is_task_complete(task): if not task.completed_at: From a8d04e1846c8123bddcdaea2812d6b2281c8627c Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Thu, 6 May 2021 00:02:52 -0700 Subject: [PATCH 17/38] extends /goals endpoint to accommodate GET method, makes POST method actually work --- app/routes.py | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/app/routes.py b/app/routes.py index 668224d06..837df602d 100644 --- a/app/routes.py +++ b/app/routes.py @@ -149,22 +149,38 @@ def mark_task_complete(task_id): } -@goals_bp.route("", methods=['POST']) +@goals_bp.route("", methods=['POST', 'GET']) def handle_goals(): - request_body = request.get_json() - new_goal = Goal(title=request_body['title']) + if request.method == "POST": + request_body = request.get_json() - response = { - "goal": { - "id": new_goal.goal_id, - "title": new_goal.title + new_goal = Goal(title=request_body['title']) + + db.session.add(new_goal) + db.session.commit() + + response = { + "goal": { + "id": new_goal.goal_id, + "title": new_goal.title + } } - } - return make_response(jsonify(response), 201) + return make_response(jsonify(response), 201) + + elif request.method == "GET": + goals = Goal.query.all() + + goals_response = [] + for goal in goals: + goals_response.append({ + "id": goal.goal_id, + "title": goal.title, + }) + return jsonify(goals_response) From e0570bcccb711c6865343c315318fdf2d67c3788 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Thu, 6 May 2021 00:05:38 -0700 Subject: [PATCH 18/38] adds /goals/ endpoint and accommodates GET method --- app/routes.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/routes.py b/app/routes.py index 837df602d..45bdc8b14 100644 --- a/app/routes.py +++ b/app/routes.py @@ -151,7 +151,6 @@ def mark_task_complete(task_id): @goals_bp.route("", methods=['POST', 'GET']) def handle_goals(): - if request.method == "POST": request_body = request.get_json() @@ -183,7 +182,17 @@ def handle_goals(): return jsonify(goals_response) +@goals_bp.route("/", methods=["GET"]) +def handle_goal(goal_id): + + goal = Goal.query.get(goal_id) + return { + "goal": { + "id": goal.goal_id, + "title": goal.title + } + } From 18cc1ea4b7a3c4b6270a4614b29e44cd6f6b896f Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Thu, 6 May 2021 00:06:48 -0700 Subject: [PATCH 19/38] creates a 404 response at / endpoint if none found --- app/routes.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/routes.py b/app/routes.py index 45bdc8b14..56d8baeb8 100644 --- a/app/routes.py +++ b/app/routes.py @@ -187,6 +187,9 @@ def handle_goal(goal_id): goal = Goal.query.get(goal_id) + if goal is None: + return make_response("", 404) + return { "goal": { "id": goal.goal_id, From 0d5945a9134457d77bca19fb4d243ae3601fbaad Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Thu, 6 May 2021 00:10:12 -0700 Subject: [PATCH 20/38] extends / endpoint to accommodate PUT method --- app/routes.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/app/routes.py b/app/routes.py index 56d8baeb8..261efcdf2 100644 --- a/app/routes.py +++ b/app/routes.py @@ -182,7 +182,7 @@ def handle_goals(): return jsonify(goals_response) -@goals_bp.route("/", methods=["GET"]) +@goals_bp.route("/", methods=["GET", "PUT"]) def handle_goal(goal_id): goal = Goal.query.get(goal_id) @@ -190,13 +190,27 @@ def handle_goal(goal_id): if goal is None: return make_response("", 404) - return { - "goal": { - "id": goal.goal_id, - "title": goal.title + if request.method == "GET": + return { + "goal": { + "id": goal.goal_id, + "title": goal.title + } } - } + elif request.method == "PUT": + request_body = request.get_json() + + goal.title = request_body["title"] + + db.session.commit() + + return { + "goal": { + "id": goal.goal_id, + "title": goal.title + } + } # Helper functions From 30126f145fc2a4b71d954c65434fcb16dc05c719 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Thu, 6 May 2021 00:12:24 -0700 Subject: [PATCH 21/38] extends / endpoint to accommodate DELETE method --- app/routes.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/routes.py b/app/routes.py index 261efcdf2..d6541dc25 100644 --- a/app/routes.py +++ b/app/routes.py @@ -182,7 +182,7 @@ def handle_goals(): return jsonify(goals_response) -@goals_bp.route("/", methods=["GET", "PUT"]) +@goals_bp.route("/", methods=["GET", "PUT", "DELETE"]) def handle_goal(goal_id): goal = Goal.query.get(goal_id) @@ -212,6 +212,13 @@ def handle_goal(goal_id): } } + elif request.method == "DELETE": + db.session.delete(goal) + db.session.commit() + + return { + "details": f"Goal {goal.goal_id} \"{goal.title}\" successfully deleted" + } # Helper functions def is_task_complete(task): From 42e7774097c00c0449fcf739aeebe0a315908518 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Thu, 6 May 2021 00:14:27 -0700 Subject: [PATCH 22/38] creates a try/except to handle invlaid data in POST method to /goals endpoint --- app/routes.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/routes.py b/app/routes.py index d6541dc25..8f4a5d16a 100644 --- a/app/routes.py +++ b/app/routes.py @@ -154,7 +154,12 @@ def handle_goals(): if request.method == "POST": request_body = request.get_json() - new_goal = Goal(title=request_body['title']) + try: + new_goal = Goal(title=request_body['title']) + except KeyError: + return make_response({ + "details": "Invalid data" + }, 400) db.session.add(new_goal) db.session.commit() From e02c18b2704e7b2d1800ee4ef1afb1e5b75faba1 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Thu, 6 May 2021 15:46:03 -0700 Subject: [PATCH 23/38] edits Task and Goal models to create one-to-many relationship --- app/__init__.py | 2 +- app/models/goal.py | 5 +++- app/models/task.py | 7 +++-- ...69934_creates_one_to_many_relationship_.py | 30 +++++++++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 migrations/versions/ed4428b69934_creates_one_to_many_relationship_.py diff --git a/app/__init__.py b/app/__init__.py index 2e7f34dd2..d88bb54c4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -33,5 +33,5 @@ def create_app(test_config=None): from .routes import tasks_bp, goals_bp app.register_blueprint(tasks_bp) app.register_blueprint(goals_bp) - + return app diff --git a/app/models/goal.py b/app/models/goal.py index 0bdc1731d..d3e65e8c8 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -1,7 +1,10 @@ -from flask import current_app +from flask import Flask, current_app +from flask_sqlalchemy import SQLAlchemy from app import db +from app.models.task import Task class Goal(db.Model): goal_id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String) + tasks = db.relationship('Task', backref='goal', lazy=True) diff --git a/app/models/task.py b/app/models/task.py index 60089ab9a..c7a333feb 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1,9 +1,10 @@ -from flask import current_app +from flask import Flask, current_app +from flask_sqlalchemy import SQLAlchemy from app import db - class Task(db.Model): task_id = db.Column(db.Integer, primary_key=True, autoincrement=True) title = db.Column(db.String) description = db.Column(db.String) - completed_at = db.Column(db.DateTime) \ No newline at end of file + completed_at = db.Column(db.DateTime) + goal_id = db.Column(db.Integer, db.ForeignKey('goal.goal_id')) \ No newline at end of file diff --git a/migrations/versions/ed4428b69934_creates_one_to_many_relationship_.py b/migrations/versions/ed4428b69934_creates_one_to_many_relationship_.py new file mode 100644 index 000000000..33fa59873 --- /dev/null +++ b/migrations/versions/ed4428b69934_creates_one_to_many_relationship_.py @@ -0,0 +1,30 @@ +"""creates one to many relationship between Task and Goal models + +Revision ID: ed4428b69934 +Revises: 0572b382dd9e +Create Date: 2021-05-06 11:39:20.607340 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ed4428b69934' +down_revision = '0572b382dd9e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('task', sa.Column('goal_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'task', 'goal', ['goal_id'], ['goal_id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'task', type_='foreignkey') + op.drop_column('task', 'goal_id') + # ### end Alembic commands ### From db980dcd18b87b6fcf4d6aca23ce7ed76adc2d4a Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Thu, 6 May 2021 17:31:51 -0700 Subject: [PATCH 24/38] adds //tasks endpoint that accommodates GET and POST methods, modifies GET method to /tasks to return goal_id as part of response --- app/models/task.py | 2 +- app/routes.py | 48 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/app/models/task.py b/app/models/task.py index c7a333feb..aae0c3136 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -7,4 +7,4 @@ class Task(db.Model): title = db.Column(db.String) description = db.Column(db.String) completed_at = db.Column(db.DateTime) - goal_id = db.Column(db.Integer, db.ForeignKey('goal.goal_id')) \ No newline at end of file + goal_id = db.Column(db.Integer, db.ForeignKey('goal.goal_id'), nullable=True) \ No newline at end of file diff --git a/app/routes.py b/app/routes.py index 8f4a5d16a..04d7c130c 100644 --- a/app/routes.py +++ b/app/routes.py @@ -77,7 +77,8 @@ def handle_task(task_id): "id": task.task_id, "title": task.title, "description": task.description, - "is_complete": is_task_complete(task) + "is_complete": is_task_complete(task), + "goal_id": task.goal_id } } @@ -148,7 +149,6 @@ def mark_task_complete(task_id): } } - @goals_bp.route("", methods=['POST', 'GET']) def handle_goals(): if request.method == "POST": @@ -225,6 +225,50 @@ def handle_goal(goal_id): "details": f"Goal {goal.goal_id} \"{goal.title}\" successfully deleted" } +@goals_bp.route("//tasks", methods=["POST", "GET"]) +def handle_tasks_for_goal(goal_id): + goal = Goal.query.get(goal_id) + + if goal is None: + return make_response("", 404) + + if request.method == "POST": + request_body = request.get_json() + + for task_id in request_body['task_ids']: + task = Task.query.get(task_id) + task.goal_id = int(goal_id) + + db.session.commit() + + return { + "id": int(goal_id), + "task_ids": request_body['task_ids'] + } + + elif request.method == "GET": + associated_tasks = Task.query.filter_by(goal_id=int(goal_id)) + + associated_tasks_info = [] + for task in associated_tasks: + task_info = { + "id": task.task_id, + "goal_id": task.goal_id, + "title": task.title, + "description": task.description, + "is_complete": is_task_complete(task) + } + associated_tasks_info.append(task_info) + + response = { + "id": int(goal_id), + "title": goal.title, + "tasks": associated_tasks_info + } + + return response + + # Helper functions def is_task_complete(task): if not task.completed_at: From fc3efd624c29a1209ade3508fb0a9fb797094cf5 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Fri, 7 May 2021 10:27:46 -0700 Subject: [PATCH 25/38] refactors /tasks/ endpoint to make goal_id presence conditional in response to GET request --- app/routes.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/routes.py b/app/routes.py index 04d7c130c..c02b2bed8 100644 --- a/app/routes.py +++ b/app/routes.py @@ -72,16 +72,20 @@ def handle_task(task_id): if request.method == "GET": - return { + response = { "task": { "id": task.task_id, "title": task.title, "description": task.description, "is_complete": is_task_complete(task), - "goal_id": task.goal_id } } + if task.goal_id: + response['task']['goal_id'] = task.goal_id + + return response + elif request.method == "PUT": request_body = request.get_json() From 84bc06144451c55888bd3b773f91131e34763d21 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Fri, 7 May 2021 11:08:12 -0700 Subject: [PATCH 26/38] adds is_task_complete() and get_task_info() methods to Task model, refactors routes.py to use new methods to DRY code --- app/models/task.py | 18 ++++++++++++- app/routes.py | 64 ++++++---------------------------------------- 2 files changed, 25 insertions(+), 57 deletions(-) diff --git a/app/models/task.py b/app/models/task.py index aae0c3136..a2fc048ba 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -7,4 +7,20 @@ class Task(db.Model): title = db.Column(db.String) description = db.Column(db.String) completed_at = db.Column(db.DateTime) - goal_id = db.Column(db.Integer, db.ForeignKey('goal.goal_id'), nullable=True) \ No newline at end of file + goal_id = db.Column(db.Integer, db.ForeignKey('goal.goal_id'), nullable=True) + + def is_task_complete(self): + if not self.completed_at: + return False + return True + + def get_task_info(self): + task_info = { + "id": self.task_id, + "title": self.title, + "description": self.description, + "is_complete": self.is_task_complete(), + } + if self.goal_id: + task_info['goal_id'] = self.goal_id + return task_info \ No newline at end of file diff --git a/app/routes.py b/app/routes.py index c02b2bed8..74a7deb2b 100644 --- a/app/routes.py +++ b/app/routes.py @@ -29,12 +29,7 @@ def handle_tasks(): db.session.commit() response = { - "task": { - "id": new_task.task_id, - "title": new_task.title, - "description": new_task.description, - "is_complete": is_task_complete(new_task) - } + "task": new_task.get_task_info() } return make_response(jsonify(response), 201) @@ -53,12 +48,7 @@ def handle_tasks(): tasks_response = [] for task in tasks: - tasks_response.append({ - "id": task.task_id, - "title": task.title, - "description":task.description, - "is_complete": is_task_complete(task) - }) + tasks_response.append(task.get_task_info()) return jsonify(tasks_response) @@ -71,23 +61,12 @@ def handle_task(task_id): return make_response("", 404) if request.method == "GET": - - response = { - "task": { - "id": task.task_id, - "title": task.title, - "description": task.description, - "is_complete": is_task_complete(task), - } + return { + "task": task.get_task_info() } - if task.goal_id: - response['task']['goal_id'] = task.goal_id - - return response elif request.method == "PUT": - request_body = request.get_json() task.title = request_body['title'] @@ -97,16 +76,10 @@ def handle_task(task_id): db.session.commit() return { - "task": { - "id": task.task_id, - "title": task.title, - "description": task.description, - "is_complete": is_task_complete(task) - } + "task": task.get_task_info() } elif request.method == "DELETE": - db.session.delete(task) db.session.commit() @@ -124,12 +97,7 @@ def mark_task_incomplete(task_id): task.completed_at = None return { - "task": { - "id": task.task_id, - "title": task.title, - "description": task.description, - "is_complete": is_task_complete(task) # False - } + "task": task.get_task_info() } @@ -145,12 +113,7 @@ def mark_task_complete(task_id): post_to_slack(f"Someone just completed the task {task.title}") return { - "task": { - "id": task.task_id, - "title": task.title, - "description": task.description, - "is_complete": is_task_complete(task) # False - } + "task": task.get_task_info() } @goals_bp.route("", methods=['POST', 'GET']) @@ -255,14 +218,7 @@ def handle_tasks_for_goal(goal_id): associated_tasks_info = [] for task in associated_tasks: - task_info = { - "id": task.task_id, - "goal_id": task.goal_id, - "title": task.title, - "description": task.description, - "is_complete": is_task_complete(task) - } - associated_tasks_info.append(task_info) + associated_tasks_info.append(task.get_task_info()) response = { "id": int(goal_id), @@ -274,10 +230,6 @@ def handle_tasks_for_goal(goal_id): # Helper functions -def is_task_complete(task): - if not task.completed_at: - return False - return True def post_to_slack(message): """ From 784773f86d14dbb7493d205848ac2a4d335b7f4a Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Fri, 7 May 2021 11:59:07 -0700 Subject: [PATCH 27/38] removes unnecessary import from routes.py --- app/routes.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/routes.py b/app/routes.py index 74a7deb2b..3ef12661a 100644 --- a/app/routes.py +++ b/app/routes.py @@ -6,7 +6,6 @@ from datetime import datetime import requests import os -from dotenv import load_dotenv tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks") goals_bp = Blueprint("goals", __name__, url_prefix="/goals") From 8e5404d3302b12d853bd0ff5dbc727dbf23dcd4d Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Mon, 10 May 2021 12:56:16 -0700 Subject: [PATCH 28/38] deletes an unnecessary import from goal.py --- app/models/goal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/goal.py b/app/models/goal.py index d3e65e8c8..320b5582e 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -1,7 +1,7 @@ from flask import Flask, current_app from flask_sqlalchemy import SQLAlchemy from app import db -from app.models.task import Task +from app.models.task import Task # is this necessary? class Goal(db.Model): From be930e8805bd498474eb9b1618c43fd590bdf8d9 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Mon, 10 May 2021 12:58:19 -0700 Subject: [PATCH 29/38] adds Procfile for Heroku deployment --- Procfile | 1 + 1 file changed, 1 insertion(+) create mode 100644 Procfile diff --git a/Procfile b/Procfile new file mode 100644 index 000000000..62e430aca --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn 'app:create_app()' \ No newline at end of file From 3ef4d7d225f59f3504492529f0a742f5678c33ae Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Tue, 11 May 2021 10:37:28 -0700 Subject: [PATCH 30/38] reorganizes routes.py to separate each HTTP method into its own route method for readability --- app/routes.py | 306 ++++++++++++++++++++++++++++---------------------- 1 file changed, 172 insertions(+), 134 deletions(-) diff --git a/app/routes.py b/app/routes.py index 3ef12661a..490ee647c 100644 --- a/app/routes.py +++ b/app/routes.py @@ -10,81 +10,95 @@ tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks") goals_bp = Blueprint("goals", __name__, url_prefix="/goals") -@tasks_bp.route("", methods=["POST", "GET"]) -def handle_tasks(): - if request.method == "POST": - request_body = request.get_json() - - try: - new_task = Task(title=request_body['title'], - description=request_body['description'], - completed_at=request_body['completed_at']) - except KeyError: - return make_response({ - "details": "Invalid data" - }, 400) - - db.session.add(new_task) - db.session.commit() - - response = { - "task": new_task.get_task_info() - } +########################## +#### /tasks Endpoints #### +########################## + +@tasks_bp.route("", methods=["POST"]) +def post_new_task(): + request_body = request.get_json() + + try: + new_task = Task(title=request_body['title'], + description=request_body['description'], + completed_at=request_body['completed_at']) + except KeyError: + return make_response({ + "details": "Invalid data" + }, 400) + + db.session.add(new_task) + db.session.commit() + + response = { + "task": new_task.get_task_info() + } - return make_response(jsonify(response), 201) + return make_response(jsonify(response), 201) - elif request.method == "GET": - sort_query = request.args.get("sort") +@tasks_bp.route("", methods=["GET"]) +def get_tasks(): + sort_query = request.args.get("sort") - if sort_query == "asc": - tasks = Task.query.order_by("title") - elif sort_query == "desc": - tasks = Task.query.order_by(desc("title")) - else: - tasks = Task.query.all() + if sort_query == "asc": + tasks = Task.query.order_by("title") + elif sort_query == "desc": + tasks = Task.query.order_by(desc("title")) + else: + tasks = Task.query.all() - tasks_response = [] + tasks_response = [] - for task in tasks: - tasks_response.append(task.get_task_info()) + for task in tasks: + tasks_response.append(task.get_task_info()) - return jsonify(tasks_response) + return jsonify(tasks_response) -@tasks_bp.route("/", methods=["GET", "PUT", "DELETE"]) -def handle_task(task_id): - + +@tasks_bp.route("/", methods=["GET"]) +def get_single_task(task_id): task = Task.query.get(task_id) + if task is None: + return make_response("", 404) + + return { + "task": task.get_task_info() + } + +@tasks_bp.route("/", methods=["PUT"]) +def edit_task(task_id): + task = Task.query.get(task_id) if task is None: return make_response("", 404) - if request.method == "GET": - return { - "task": task.get_task_info() - } + request_body = request.get_json() + task.title = request_body['title'] + task.description = request_body['description'] + task.completed_at = request_body['completed_at'] - elif request.method == "PUT": - request_body = request.get_json() + db.session.commit() - task.title = request_body['title'] - task.description = request_body['description'] - task.completed_at = request_body['completed_at'] + return { + "task": task.get_task_info() + } - db.session.commit() - return { - "task": task.get_task_info() - } +@tasks_bp.route("/", methods=["DELETE"]) +def delete_task(task_id): + task = Task.query.get(task_id) + if task is None: + return make_response("", 404) - elif request.method == "DELETE": - db.session.delete(task) - db.session.commit() + db.session.delete(task) + db.session.commit() + + return { + "details": f"Task {task.task_id} \"{task.title}\" successfully deleted" + } - return { - "details": f"Task {task.task_id} \"{task.title}\" successfully deleted" - } @tasks_bp.route("//mark_incomplete", methods=["PATCH"]) def mark_task_incomplete(task_id): @@ -115,120 +129,144 @@ def mark_task_complete(task_id): "task": task.get_task_info() } -@goals_bp.route("", methods=['POST', 'GET']) -def handle_goals(): - if request.method == "POST": - request_body = request.get_json() - - try: - new_goal = Goal(title=request_body['title']) - except KeyError: - return make_response({ - "details": "Invalid data" - }, 400) - - db.session.add(new_goal) - db.session.commit() - - response = { - "goal": { - "id": new_goal.goal_id, - "title": new_goal.title - } + + +########################## +#### /goals Endpoints #### +########################## + +@goals_bp.route("", methods=['POST']) +def post_new_goal(): + request_body = request.get_json() + + try: + new_goal = Goal(title=request_body['title']) + except KeyError: + return make_response({ + "details": "Invalid data" + }, 400) + + db.session.add(new_goal) + db.session.commit() + + response = { + "goal": { + "id": new_goal.goal_id, + "title": new_goal.title } + } - return make_response(jsonify(response), 201) + return make_response(jsonify(response), 201) - elif request.method == "GET": - goals = Goal.query.all() - goals_response = [] +@goals_bp.route("", methods=['GET']) +def get_goals(): + goals = Goal.query.all() - for goal in goals: - goals_response.append({ - "id": goal.goal_id, - "title": goal.title, - }) + goals_response = [] - return jsonify(goals_response) + for goal in goals: + goals_response.append({ + "id": goal.goal_id, + "title": goal.title, + }) + return jsonify(goals_response) -@goals_bp.route("/", methods=["GET", "PUT", "DELETE"]) -def handle_goal(goal_id): +@goals_bp.route("/", methods=['GET']) +def get_single_goal(goal_id): goal = Goal.query.get(goal_id) - if goal is None: return make_response("", 404) - if request.method == "GET": - return { - "goal": { - "id": goal.goal_id, - "title": goal.title - } + return { + "goal": { + "id": goal.goal_id, + "title": goal.title } + } - elif request.method == "PUT": - request_body = request.get_json() - goal.title = request_body["title"] +@goals_bp.route("/", methods=['PUT']) +def edit_goal(goal_id): + goal = Goal.query.get(goal_id) + if goal is None: + return make_response("", 404) - db.session.commit() + request_body = request.get_json() - return { - "goal": { - "id": goal.goal_id, - "title": goal.title - } - } + goal.title = request_body["title"] - elif request.method == "DELETE": - db.session.delete(goal) - db.session.commit() + db.session.commit() - return { - "details": f"Goal {goal.goal_id} \"{goal.title}\" successfully deleted" + return { + "goal": { + "id": goal.goal_id, + "title": goal.title } + } -@goals_bp.route("//tasks", methods=["POST", "GET"]) -def handle_tasks_for_goal(goal_id): + +@goals_bp.route("/", methods=["DELETE"]) +def delete_goal(goal_id): goal = Goal.query.get(goal_id) + if goal is None: + return make_response("", 404) + + db.session.delete(goal) + db.session.commit() + return { + "details": f"Goal {goal.goal_id} \"{goal.title}\" successfully deleted" + } + + +@goals_bp.route("//tasks", methods=["POST"]) +def post_tasks_for_goal(goal_id): + goal = Goal.query.get(goal_id) if goal is None: return make_response("", 404) - if request.method == "POST": - request_body = request.get_json() + request_body = request.get_json() - for task_id in request_body['task_ids']: - task = Task.query.get(task_id) - task.goal_id = int(goal_id) - - db.session.commit() + for task_id in request_body['task_ids']: + task = Task.query.get(task_id) + task.goal_id = int(goal_id) + + db.session.commit() - return { - "id": int(goal_id), - "task_ids": request_body['task_ids'] - } + return { + "id": int(goal_id), + "task_ids": request_body['task_ids'] + } - elif request.method == "GET": - associated_tasks = Task.query.filter_by(goal_id=int(goal_id)) - associated_tasks_info = [] - for task in associated_tasks: - associated_tasks_info.append(task.get_task_info()) +@goals_bp.route("//tasks", methods=["GET"]) +def get_tasks_for_goal(goal_id): + goal = Goal.query.get(goal_id) + if goal is None: + return make_response("", 404) - response = { - "id": int(goal_id), - "title": goal.title, - "tasks": associated_tasks_info - } + associated_tasks = Task.query.filter_by(goal_id=int(goal_id)) + + associated_tasks_info = [] + for task in associated_tasks: + associated_tasks_info.append(task.get_task_info()) + + response = { + "id": int(goal_id), + "title": goal.title, + "tasks": associated_tasks_info + } + + return response - return response -# Helper functions +########################## +#### Helper Functions #### +########################## def post_to_slack(message): """ From 47c647d99e08f2558e699b1cbe46a35e08d202e0 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 12 May 2021 21:06:49 -0700 Subject: [PATCH 31/38] adds from_json() method to Task model and updates PUT method to /tasks/ to use method --- app/models/task.py | 8 +++++++- app/routes.py | 4 +--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/models/task.py b/app/models/task.py index a2fc048ba..db2100a9a 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -23,4 +23,10 @@ def get_task_info(self): } if self.goal_id: task_info['goal_id'] = self.goal_id - return task_info \ No newline at end of file + return task_info + + def from_json(self, request_body): + self.title = request_body['title'] + self.description = request_body['description'] + self.completed_at = request_body['completed_at'] + return self \ No newline at end of file diff --git a/app/routes.py b/app/routes.py index 490ee647c..ca12c7778 100644 --- a/app/routes.py +++ b/app/routes.py @@ -75,9 +75,7 @@ def edit_task(task_id): request_body = request.get_json() - task.title = request_body['title'] - task.description = request_body['description'] - task.completed_at = request_body['completed_at'] + task = task.from_json(request_body) db.session.commit() From e34eb525550c126897a110ca0975f451683abf7e Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 12 May 2021 21:30:32 -0700 Subject: [PATCH 32/38] adds a to_json() method to Goal model, updates routes.py to utilize method --- app/models/goal.py | 6 ++++++ app/routes.py | 15 +++------------ 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/app/models/goal.py b/app/models/goal.py index 320b5582e..4bf8afbc9 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -8,3 +8,9 @@ class Goal(db.Model): goal_id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String) tasks = db.relationship('Task', backref='goal', lazy=True) + + def to_json(self): + return { + "id": self.goal_id, + "title": self.title + } \ No newline at end of file diff --git a/app/routes.py b/app/routes.py index ca12c7778..f204f67a1 100644 --- a/app/routes.py +++ b/app/routes.py @@ -164,10 +164,7 @@ def get_goals(): goals_response = [] for goal in goals: - goals_response.append({ - "id": goal.goal_id, - "title": goal.title, - }) + goals_response.append(goal.to_json()) return jsonify(goals_response) @@ -179,10 +176,7 @@ def get_single_goal(goal_id): return make_response("", 404) return { - "goal": { - "id": goal.goal_id, - "title": goal.title - } + "goal": goal.to_json() } @@ -199,10 +193,7 @@ def edit_goal(goal_id): db.session.commit() return { - "goal": { - "id": goal.goal_id, - "title": goal.title - } + "goal": goal.to_json() } From d5ccb01253a4755216a8e96a40db6fbb1b2ee2d3 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 12 May 2021 21:32:21 -0700 Subject: [PATCH 33/38] refactors POST /goals route to utilize .to_json() method of Goal model --- app/routes.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/routes.py b/app/routes.py index f204f67a1..cfb02dfc2 100644 --- a/app/routes.py +++ b/app/routes.py @@ -148,10 +148,7 @@ def post_new_goal(): db.session.commit() response = { - "goal": { - "id": new_goal.goal_id, - "title": new_goal.title - } + "goal": new_goal.to_json() } return make_response(jsonify(response), 201) From 7685c3a4706a26db88653e1e0ba0f5f5faa13d34 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 12 May 2021 21:34:51 -0700 Subject: [PATCH 34/38] refactors //tasks endpoint GET method to utilize Goal's to_json method --- app/routes.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/routes.py b/app/routes.py index cfb02dfc2..4c0ca617e 100644 --- a/app/routes.py +++ b/app/routes.py @@ -240,11 +240,8 @@ def get_tasks_for_goal(goal_id): for task in associated_tasks: associated_tasks_info.append(task.get_task_info()) - response = { - "id": int(goal_id), - "title": goal.title, - "tasks": associated_tasks_info - } + response = goal.to_json() + response['tasks'] = associated_tasks_info return response From 96632bb2d23f55e165f62ace7ce9894f2b8c6d35 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 12 May 2021 21:40:58 -0700 Subject: [PATCH 35/38] refactors routes.py to use list comprehension --- app/routes.py | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/app/routes.py b/app/routes.py index 4c0ca617e..268a6ba48 100644 --- a/app/routes.py +++ b/app/routes.py @@ -48,12 +48,7 @@ def get_tasks(): else: tasks = Task.query.all() - tasks_response = [] - - for task in tasks: - tasks_response.append(task.get_task_info()) - - return jsonify(tasks_response) + return jsonify([task.get_task_info() for task in tasks]) @tasks_bp.route("/", methods=["GET"]) @@ -158,12 +153,7 @@ def post_new_goal(): def get_goals(): goals = Goal.query.all() - goals_response = [] - - for goal in goals: - goals_response.append(goal.to_json()) - - return jsonify(goals_response) + return jsonify([goal.to_json() for goal in goals]) @goals_bp.route("/", methods=['GET']) @@ -236,12 +226,8 @@ def get_tasks_for_goal(goal_id): associated_tasks = Task.query.filter_by(goal_id=int(goal_id)) - associated_tasks_info = [] - for task in associated_tasks: - associated_tasks_info.append(task.get_task_info()) - response = goal.to_json() - response['tasks'] = associated_tasks_info + response['tasks'] = [task.get_task_info() for task in associated_tasks] return response From dd3ad40a6eb013af2ea4baca3f28a3b4749b6638 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 12 May 2021 21:59:56 -0700 Subject: [PATCH 36/38] adds option to sort tasks by id and adds tests for these additions --- app/routes.py | 5 +++ tests/test_optional_enhancements.py | 51 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/test_optional_enhancements.py diff --git a/app/routes.py b/app/routes.py index 268a6ba48..3018a5d0d 100644 --- a/app/routes.py +++ b/app/routes.py @@ -40,11 +40,16 @@ def post_new_task(): @tasks_bp.route("", methods=["GET"]) def get_tasks(): sort_query = request.args.get("sort") + sort_by_id_query = request.args.get("sort_by_id") if sort_query == "asc": tasks = Task.query.order_by("title") elif sort_query == "desc": tasks = Task.query.order_by(desc("title")) + elif sort_by_id_query == "asc": + tasks = Task.query.order_by("task_id") + elif sort_by_id_query == "desc": + tasks = Task.query.order_by(desc("task_id")) else: tasks = Task.query.all() diff --git a/tests/test_optional_enhancements.py b/tests/test_optional_enhancements.py new file mode 100644 index 000000000..59094e5d6 --- /dev/null +++ b/tests/test_optional_enhancements.py @@ -0,0 +1,51 @@ +def test_get_tasks_sorted_by_id(client, three_tasks): + # Act + response = client.get("/tasks?sort_by_id=asc") + response_body = response.get_json() + + # Assert + assert response.status_code == 200 + assert len(response_body) == 3 + assert response_body == [ + { + "description": "", + "id": 1, + "is_complete": False, + "title": "Water the garden 🌷"}, + { + "description": "", + "id": 2, + "is_complete": False, + "title": "Answer forgotten email 📧"}, + { + "description": "", + "id": 3, + "is_complete": False, + "title": "Pay my outstanding tickets 😭"} + ] + +def test_get_tasks_sorted_by_id_desc(client, three_tasks): + # Act + response = client.get("/tasks?sort_by_id=desc") + response_body = response.get_json() + + # Assert + assert response.status_code == 200 + assert len(response_body) == 3 + assert response_body == [ + { + "description": "", + "id": 3, + "is_complete": False, + "title": "Pay my outstanding tickets 😭"}, + { + "description": "", + "id": 2, + "is_complete": False, + "title": "Answer forgotten email 📧"}, + { + "description": "", + "id": 1, + "is_complete": False, + "title": "Water the garden 🌷"} + ] \ No newline at end of file From bca49fc8875df849905a36a4e0021d979f523891 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 12 May 2021 22:07:26 -0700 Subject: [PATCH 37/38] adds option to filter tasks by title and adds test for this option --- app/routes.py | 3 +++ tests/test_optional_enhancements.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/app/routes.py b/app/routes.py index 3018a5d0d..cc8e77fce 100644 --- a/app/routes.py +++ b/app/routes.py @@ -41,6 +41,7 @@ def post_new_task(): def get_tasks(): sort_query = request.args.get("sort") sort_by_id_query = request.args.get("sort_by_id") + filter_by_query = request.args.get("filter_by_title") if sort_query == "asc": tasks = Task.query.order_by("title") @@ -50,6 +51,8 @@ def get_tasks(): tasks = Task.query.order_by("task_id") elif sort_by_id_query == "desc": tasks = Task.query.order_by(desc("task_id")) + elif filter_by_query: + tasks = Task.query.filter_by(title=filter_by_query) else: tasks = Task.query.all() diff --git a/tests/test_optional_enhancements.py b/tests/test_optional_enhancements.py index 59094e5d6..f50dedbbd 100644 --- a/tests/test_optional_enhancements.py +++ b/tests/test_optional_enhancements.py @@ -48,4 +48,20 @@ def test_get_tasks_sorted_by_id_desc(client, three_tasks): "id": 1, "is_complete": False, "title": "Water the garden 🌷"} + ] + +def test_get_tasks_filtered_by_title(client, three_tasks): + # Act + response = client.get("/tasks?filter_by_title=Answer%20forgotten%20email%20📧") + response_body = response.get_json() + + # Assert + assert response.status_code == 200 + assert len(response_body) == 1 + assert response_body == [ + { + "description": "", + "id": 2, + "is_complete": False, + "title": "Answer forgotten email 📧"} ] \ No newline at end of file From 1921b7a2cdd670051faf4d5b269220d4ddc01665 Mon Sep 17 00:00:00 2001 From: Whit Sundby Date: Wed, 12 May 2021 22:29:56 -0700 Subject: [PATCH 38/38] adds query param options to /goals endpoint, adds corresponding tests to optional enhancements test file, adds a fixture to conftest.py to make these tests work --- app/routes.py | 17 ++++- tests/conftest.py | 12 ++++ tests/test_optional_enhancements.py | 108 ++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/app/routes.py b/app/routes.py index cc8e77fce..4420fb4fe 100644 --- a/app/routes.py +++ b/app/routes.py @@ -159,7 +159,22 @@ def post_new_goal(): @goals_bp.route("", methods=['GET']) def get_goals(): - goals = Goal.query.all() + sort_query = request.args.get("sort") + sort_by_id_query = request.args.get("sort_by_id") + filter_by_title_query = request.args.get("filter_by_title") + + if sort_query == "asc": + goals = Goal.query.order_by("title") + elif sort_query == "desc": + goals = Goal.query.order_by(desc("title")) + elif sort_by_id_query == "asc": + goals = Goal.query.order_by("goal_id") + elif sort_by_id_query == "desc": + goals = Goal.query.order_by(desc("goal_id")) + elif filter_by_title_query: + goals = Goal.query.filter_by(title=filter_by_title_query) + else: + goals = Goal.query.all() return jsonify([goal.to_json() for goal in goals]) diff --git a/tests/conftest.py b/tests/conftest.py index d11083bf3..8c9e4318b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -86,3 +86,15 @@ def one_task_belongs_to_one_goal(app, one_goal, one_task): goal = Goal.query.first() goal.tasks.append(task) db.session.commit() + +# This fixture gets called in optional enchancements tests +# that reference "three_goals". This fixture creates three +# goals and saves them in the database +@pytest.fixture +def three_goals(app): + db.session.add_all([ + Goal(title="Run an ultramarathon"), + Goal(title="Go solo paragliding"), + Goal(title="Save a million dollars") + ]) + db.session.commit() \ No newline at end of file diff --git a/tests/test_optional_enhancements.py b/tests/test_optional_enhancements.py index f50dedbbd..d91255b4d 100644 --- a/tests/test_optional_enhancements.py +++ b/tests/test_optional_enhancements.py @@ -64,4 +64,112 @@ def test_get_tasks_filtered_by_title(client, three_tasks): "id": 2, "is_complete": False, "title": "Answer forgotten email 📧"} + ] + +def test_get_goals_sorted_asc(client, three_goals): + # Act + response = client.get("/goals?sort=asc") + response_body = response.get_json() + + # Assert + assert response.status_code == 200 + assert len(response_body) == 3 + assert response_body == [ + { + "id": 2, + "title": "Go solo paragliding" + }, + { + "id": 1, + "title": "Run an ultramarathon" + }, + { + "id": 3, + "title": "Save a million dollars" + } + ] + + +def test_get_goals_sorted_desc(client, three_goals): + # Act + response = client.get("/goals?sort=desc") + response_body = response.get_json() + + # Assert + assert response.status_code == 200 + assert len(response_body) == 3 + assert response_body == [ + { + "id": 3, + "title": "Save a million dollars" + }, + { + "id": 1, + "title": "Run an ultramarathon" + }, + { + "id": 2, + "title": "Go solo paragliding" + } + ] + +def test_get_goals_sorted_by_id(client, three_goals): + # Act + response = client.get("/goals?sort_by_id=asc") + response_body = response.get_json() + + # Assert + assert response.status_code == 200 + assert len(response_body) == 3 + assert response_body == [ + { + "id": 1, + "title": "Run an ultramarathon" + }, + { + "id": 2, + "title": "Go solo paragliding" + }, + { + "id": 3, + "title": "Save a million dollars" + } + ] + +def test_get_goals_sorted_by_id_desc(client, three_goals): + # Act + response = client.get("/goals?sort_by_id=desc") + response_body = response.get_json() + + # Assert + assert response.status_code == 200 + assert len(response_body) == 3 + assert response_body == [ + { + "id": 3, + "title": "Save a million dollars" + }, + { + "id": 2, + "title": "Go solo paragliding" + }, + { + "id": 1, + "title": "Run an ultramarathon" + } + ] + +def test_get_goals_filtered_by_title(client, three_goals): + # Act + response = client.get("/goals?filter_by_title=Go%20solo%20paragliding") + response_body = response.get_json() + + # Assert + assert response.status_code == 200 + assert len(response_body) == 1 + assert response_body == [ + { + "id": 2, + "title": "Go solo paragliding" + } ] \ No newline at end of file