From 2323fad99fd86e8704125ad3b8858295c6ad27f4 Mon Sep 17 00:00:00 2001 From: Charday Neal Date: Fri, 1 Nov 2024 13:44:19 -0700 Subject: [PATCH 01/14] Create Task Model, Initial Setup, Enable Tests Complete initial setup of project, including migrations directory being using for Task Model changes. Create tasks endpoint for creating, reading, updating and deleting. Enable few tests from Wave 01 --- app/__init__.py | 2 + app/models/task.py | 15 +++ app/routes/task_routes.py | 65 +++++++++- migrations/README | 1 + migrations/alembic.ini | 50 ++++++++ migrations/env.py | 113 ++++++++++++++++++ migrations/script.py.mako | 24 ++++ .../versions/117660d70636_add_task_model.py | 39 ++++++ tests/test_wave_01.py | 11 +- 9 files changed, 314 insertions(+), 6 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/117660d70636_add_task_model.py diff --git a/app/__init__.py b/app/__init__.py index 3c581ceeb..80dc0d60a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,6 +1,7 @@ from flask import Flask from .db import db, migrate from .models import task, goal +from .routes.task_routes import tasks_bp import os def create_app(config=None): @@ -18,5 +19,6 @@ def create_app(config=None): migrate.init_app(app, db) # Register Blueprints here + app.register_blueprint(tasks_bp) return app diff --git a/app/models/task.py b/app/models/task.py index 5d99666a4..7b3fb75ba 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1,5 +1,20 @@ +from datetime import datetime from sqlalchemy.orm import Mapped, mapped_column +from typing import Optional from ..db import db class Task(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + title: Mapped[str] + description: Mapped[str] + completed_at: Mapped[Optional[datetime]] + + def to_dict(self): + return { + "id": self.id, + "title": self.title, + "description": self.description, + "is_complete": True if self.completed_at else False + } + + \ No newline at end of file diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 3aae38d49..77bdd77ba 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1 +1,64 @@ -from flask import Blueprint \ No newline at end of file +from flask import Blueprint, abort, make_response, request, Response +from ..models.task import Task +from ..db import db + +tasks_bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") + +@tasks_bp.post("") +def create_task(): + request_body = request.get_json() + + title = request_body["title"] + description = request_body["description"] + new_task = Task(title=title, description=description) + + db.session.add(new_task) + db.session.commit() + + return {"task": new_task.to_dict()}, 201 + +@tasks_bp.get("") +def get_all_tasks(): + tasks_query = db.select(Task).order_by(Task.id) + tasks = db.session.scalars(tasks_query) + + return [task.to_dict() for task in tasks], 200 + +@tasks_bp.get("/") +def get_saved_task_by_id(task_id): + task = validate_task(task_id) + return {"task": task.to_dict()}, 200 + +@tasks_bp.put("/") +def update_task(task_id): + task = validate_task(task_id) + request_body = request.get_json() + + task.title = request_body.get("title", task.title) + task.description = request_body.get("description", task.description) + + db.session.commit() + + return {"task": task.to_dict()}, 200 + +@tasks_bp.delete("/") +def delete_task(task_id): + task = validate_task(task_id) + + db.session.delete(task) + db.session.commit() + + return {"details": f"Task {task_id} \"{task.title}\" successfully deleted"}, 200 + +def validate_task(task_id): + try: + task_id = int(task_id) + except ValueError: + abort(make_response({"message": f"Task {task_id} incorrect value, expected integer"}, 400)) + + task_query = db.select(Task).where(Task.id == task_id) + task = db.session.scalar(task_query) + + if not task: + abort(make_response({"message": f"Task {task_id} not found"}, 404)) + return task \ No newline at end of file diff --git a/migrations/README b/migrations/README new file mode 100644 index 000000000..0e0484415 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 000000000..ec9d45c26 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# 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,flask_migrate + +[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 + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[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..4c9709271 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +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') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# 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', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# 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 get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +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=get_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.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_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/117660d70636_add_task_model.py b/migrations/versions/117660d70636_add_task_model.py new file mode 100644 index 000000000..4b11e7eb5 --- /dev/null +++ b/migrations/versions/117660d70636_add_task_model.py @@ -0,0 +1,39 @@ +"""Add Task model + +Revision ID: 117660d70636 +Revises: +Create Date: 2024-10-31 18:45:44.798098 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '117660d70636' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('goal', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('task', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=False), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('task') + op.drop_table('goal') + # ### end Alembic commands ### diff --git a/tests/test_wave_01.py b/tests/test_wave_01.py index dca626d78..69c055499 100644 --- a/tests/test_wave_01.py +++ b/tests/test_wave_01.py @@ -2,7 +2,7 @@ import pytest -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_tasks_no_saved_tasks(client): # Act response = client.get("/tasks") @@ -13,7 +13,7 @@ def test_get_tasks_no_saved_tasks(client): assert response_body == [] -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_tasks_one_saved_tasks(client, one_task): # Act response = client.get("/tasks") @@ -32,7 +32,7 @@ def test_get_tasks_one_saved_tasks(client, one_task): ] -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_task(client, one_task): # Act response = client.get("/tasks/1") @@ -64,6 +64,7 @@ def test_get_task_not_found(client): # ***************************************************************** # **Complete test with assertion about response body*************** # ***************************************************************** + @pytest.mark.skip(reason="No way to test this feature yet") @@ -93,7 +94,7 @@ def test_create_task(client): assert new_task.completed_at == None -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_update_task(client, one_task): # Act response = client.put("/tasks/1", json={ @@ -137,7 +138,7 @@ def test_update_task_not_found(client): # ***************************************************************** -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_delete_task(client, one_task): # Act response = client.delete("/tasks/1") From 1fc5015b7adb0ac0b3b9f8da959a2d260238c772 Mon Sep 17 00:00:00 2001 From: Charday Neal Date: Fri, 1 Nov 2024 14:28:14 -0700 Subject: [PATCH 02/14] Update Tasks Route Update Endpoint Create helper function to ensure task is created with proper details. Handle appropriately should we receive request with invalid details for new task. --- app/models/task.py | 4 +--- app/routes/task_routes.py | 16 ++++++++++++---- tests/test_wave_01.py | 35 ++++++++++++----------------------- 3 files changed, 25 insertions(+), 30 deletions(-) diff --git a/app/models/task.py b/app/models/task.py index 7b3fb75ba..e83f542ae 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -15,6 +15,4 @@ def to_dict(self): "title": self.title, "description": self.description, "is_complete": True if self.completed_at else False - } - - \ No newline at end of file + } \ No newline at end of file diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 77bdd77ba..e904b66ef 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1,12 +1,12 @@ from flask import Blueprint, abort, make_response, request, Response -from ..models.task import Task -from ..db import db +from app.models.task import Task +from app.db import db tasks_bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") @tasks_bp.post("") def create_task(): - request_body = request.get_json() + request_body = is_valid_task(request.get_json()) title = request_body["title"] description = request_body["description"] @@ -27,6 +27,7 @@ def get_all_tasks(): @tasks_bp.get("/") def get_saved_task_by_id(task_id): task = validate_task(task_id) + return {"task": task.to_dict()}, 200 @tasks_bp.put("/") @@ -61,4 +62,11 @@ def validate_task(task_id): if not task: abort(make_response({"message": f"Task {task_id} not found"}, 404)) - return task \ No newline at end of file + + return task + +def is_valid_task(task_body): + if "title" in task_body and "description" in task_body: + return task_body + + abort(make_response({"details": "Invalid data"}, 400)) \ No newline at end of file diff --git a/tests/test_wave_01.py b/tests/test_wave_01.py index 69c055499..ddfe3a8f6 100644 --- a/tests/test_wave_01.py +++ b/tests/test_wave_01.py @@ -51,7 +51,7 @@ def test_get_task(client, one_task): } -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_task_not_found(client): # Act response = client.get("/tasks/1") @@ -59,15 +59,11 @@ def test_get_task_not_found(client): # Assert assert response.status_code == 404 - - raise Exception("Complete test with assertion about response body") - # ***************************************************************** - # **Complete test with assertion about response body*************** - # ***************************************************************** - + assert "message" in response_body + assert response_body == {"message": f"Task 1 not found"} -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_create_task(client): # Act response = client.post("/tasks", json={ @@ -120,7 +116,7 @@ def test_update_task(client, one_task): assert task.completed_at == None -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_update_task_not_found(client): # Act response = client.put("/tasks/1", json={ @@ -131,11 +127,8 @@ def test_update_task_not_found(client): # Assert assert response.status_code == 404 - - raise Exception("Complete test with assertion about response body") - # ***************************************************************** - # **Complete test with assertion about response body*************** - # ***************************************************************** + assert "message" in response_body + assert response_body == {"message": f"Task 1 not found"} # @pytest.mark.skip(reason="No way to test this feature yet") @@ -153,7 +146,7 @@ def test_delete_task(client, one_task): assert Task.query.get(1) == None -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_delete_task_not_found(client): # Act response = client.delete("/tasks/1") @@ -161,16 +154,12 @@ def test_delete_task_not_found(client): # Assert assert response.status_code == 404 - - raise Exception("Complete test with assertion about response body") - # ***************************************************************** - # **Complete test with assertion about response body*************** - # ***************************************************************** - + assert "message" in response_body + assert response_body == {"message": f"Task 1 not found"} assert Task.query.all() == [] -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_create_task_must_contain_title(client): # Act response = client.post("/tasks", json={ @@ -187,7 +176,7 @@ def test_create_task_must_contain_title(client): assert Task.query.all() == [] -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_create_task_must_contain_description(client): # Act response = client.post("/tasks", json={ From ac1d138493296fd5934f67790aaa603b4ace9330 Mon Sep 17 00:00:00 2001 From: Charday Neal Date: Fri, 1 Nov 2024 15:36:36 -0700 Subject: [PATCH 03/14] Update tasks route GET endpoint Implement sorting feature by adding sort query param that sorts tasks by title, ascending and descending --- app/routes/task_routes.py | 13 +++++++++++-- tests/test_wave_02.py | 4 ++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index e904b66ef..f5ea66d8e 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1,4 +1,5 @@ from flask import Blueprint, abort, make_response, request, Response +from sqlalchemy import desc from app.models.task import Task from app.db import db @@ -19,8 +20,16 @@ def create_task(): @tasks_bp.get("") def get_all_tasks(): - tasks_query = db.select(Task).order_by(Task.id) - tasks = db.session.scalars(tasks_query) + sort_param = request.args.get("sort") + query = db.select(Task) + + if sort_param: + if sort_param == "desc": + query = query.order_by(desc(Task.title)) + else: + query = query.order_by(Task.title) + + tasks = db.session.scalars(query) return [task.to_dict() for task in tasks], 200 diff --git a/tests/test_wave_02.py b/tests/test_wave_02.py index a087e0909..651e3aebd 100644 --- a/tests/test_wave_02.py +++ b/tests/test_wave_02.py @@ -1,7 +1,7 @@ import pytest -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_tasks_sorted_asc(client, three_tasks): # Act response = client.get("/tasks?sort=asc") @@ -29,7 +29,7 @@ def test_get_tasks_sorted_asc(client, three_tasks): ] -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_tasks_sorted_desc(client, three_tasks): # Act response = client.get("/tasks?sort=desc") From 56d6e53b8a9ec237dc8b5e455de22d2400301471 Mon Sep 17 00:00:00 2001 From: Charday Neal Date: Tue, 5 Nov 2024 20:28:54 -0800 Subject: [PATCH 04/14] Implement helper functions/ Add Endpoints Created validate_model and create_model helper methods to DRY up code and create reusable functions Created an endpoint that marks tasks complete and incomplete Updated task model only to revert changes to resolve issue. --- app/models/task.py | 11 +++- app/routes/route_utilities.py | 33 ++++++++++ app/routes/task_routes.py | 61 ++++++++----------- .../457a80026c3f_update_task_model.py | 38 ++++++++++++ ...07dc03f9f9_revert_changes_to_task_model.py | 38 ++++++++++++ tests/test_wave_01.py | 6 +- tests/test_wave_03.py | 8 +-- 7 files changed, 151 insertions(+), 44 deletions(-) create mode 100644 app/routes/route_utilities.py create mode 100644 migrations/versions/457a80026c3f_update_task_model.py create mode 100644 migrations/versions/8607dc03f9f9_revert_changes_to_task_model.py diff --git a/app/models/task.py b/app/models/task.py index e83f542ae..8b0eade63 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -7,12 +7,17 @@ class Task(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] description: Mapped[str] - completed_at: Mapped[Optional[datetime]] + completed_at: Mapped[Optional[datetime]] def to_dict(self): - return { + task_to_dict = { "id": self.id, "title": self.title, "description": self.description, "is_complete": True if self.completed_at else False - } \ No newline at end of file + } + return task_to_dict + + @classmethod + def from_dict(cls, task_dict): + return Task(title=task_dict["title"] , description=task_dict["description"]) \ No newline at end of file diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py new file mode 100644 index 000000000..81bf3b824 --- /dev/null +++ b/app/routes/route_utilities.py @@ -0,0 +1,33 @@ +from flask import abort, make_response +from app.db import db + +def create_model(cls, model_data): + try: + model = cls.from_dict(model_data) + + except KeyError as error: + response = {"details": f"Invalid data"} + abort(make_response(response, 400)) + + db.session.add(model) + db.session.commit() + + return {"task": model.to_dict()}, 201 + +def validate_model(cls, model_id): + try: + model_id = int(model_id) + except ValueError: + response = {"message": f"Invalid request: {cls.__name__} id {model_id} invalid"} + abort(make_response(response, 400)) + + query = db.select(cls).where(cls.id == model_id) + model = db.session.scalar(query) + + if model: + return model + + response = {"message": f"Invalid request: {cls.__name__} {model_id} not found"} + abort(make_response(response, 404)) + + \ No newline at end of file diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index f5ea66d8e..480a74af5 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1,5 +1,7 @@ -from flask import Blueprint, abort, make_response, request, Response +from flask import Blueprint, request from sqlalchemy import desc +from datetime import datetime +from app.routes.route_utilities import create_model, validate_model from app.models.task import Task from app.db import db @@ -7,16 +9,8 @@ @tasks_bp.post("") def create_task(): - request_body = is_valid_task(request.get_json()) - - title = request_body["title"] - description = request_body["description"] - new_task = Task(title=title, description=description) - - db.session.add(new_task) - db.session.commit() - - return {"task": new_task.to_dict()}, 201 + request_body = request.get_json() + return create_model(Task, request_body) @tasks_bp.get("") def get_all_tasks(): @@ -35,13 +29,13 @@ def get_all_tasks(): @tasks_bp.get("/") def get_saved_task_by_id(task_id): - task = validate_task(task_id) + task = validate_model(Task, task_id) return {"task": task.to_dict()}, 200 @tasks_bp.put("/") def update_task(task_id): - task = validate_task(task_id) + task = validate_model(Task, task_id) request_body = request.get_json() task.title = request_body.get("title", task.title) @@ -51,31 +45,30 @@ def update_task(task_id): return {"task": task.to_dict()}, 200 -@tasks_bp.delete("/") -def delete_task(task_id): - task = validate_task(task_id) - - db.session.delete(task) +@tasks_bp.patch("//mark_complete") +def update_task_as_complete(task_id): + task = validate_model(Task, task_id) + task.completed_at = datetime.now() + db.session.commit() - return {"details": f"Task {task_id} \"{task.title}\" successfully deleted"}, 200 - -def validate_task(task_id): - try: - task_id = int(task_id) - except ValueError: - abort(make_response({"message": f"Task {task_id} incorrect value, expected integer"}, 400)) + # make call to Slack API, post message to workspace and channel + return {"task": task.to_dict()}, 200 - task_query = db.select(Task).where(Task.id == task_id) - task = db.session.scalar(task_query) +@tasks_bp.patch("//mark_incomplete") +def update_task_as_incomplete(task_id): + task = validate_model(Task, task_id) + task.completed_at = None - if not task: - abort(make_response({"message": f"Task {task_id} not found"}, 404)) + db.session.commit() - return task + return {"task": task.to_dict()}, 200 -def is_valid_task(task_body): - if "title" in task_body and "description" in task_body: - return task_body +@tasks_bp.delete("/") +def delete_task(task_id): + task = validate_model(Task, task_id) - abort(make_response({"details": "Invalid data"}, 400)) \ No newline at end of file + db.session.delete(task) + db.session.commit() + + return {"details": f"Task {task_id} \"{task.title}\" successfully deleted"}, 200 \ No newline at end of file diff --git a/migrations/versions/457a80026c3f_update_task_model.py b/migrations/versions/457a80026c3f_update_task_model.py new file mode 100644 index 000000000..4abcae8bf --- /dev/null +++ b/migrations/versions/457a80026c3f_update_task_model.py @@ -0,0 +1,38 @@ +"""Update Task model + +Revision ID: 457a80026c3f +Revises: 117660d70636 +Create Date: 2024-11-05 18:52:18.807853 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '457a80026c3f' +down_revision = '117660d70636' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('task', schema=None) as batch_op: + batch_op.alter_column('completed_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.Date(), + existing_nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('task', schema=None) as batch_op: + batch_op.alter_column('completed_at', + existing_type=sa.Date(), + type_=postgresql.TIMESTAMP(), + existing_nullable=True) + + # ### end Alembic commands ### diff --git a/migrations/versions/8607dc03f9f9_revert_changes_to_task_model.py b/migrations/versions/8607dc03f9f9_revert_changes_to_task_model.py new file mode 100644 index 000000000..7e13029ef --- /dev/null +++ b/migrations/versions/8607dc03f9f9_revert_changes_to_task_model.py @@ -0,0 +1,38 @@ +"""Revert changes to Task model + +Revision ID: 8607dc03f9f9 +Revises: 457a80026c3f +Create Date: 2024-11-05 19:24:17.030192 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8607dc03f9f9' +down_revision = '457a80026c3f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('task', schema=None) as batch_op: + batch_op.alter_column('completed_at', + existing_type=sa.DATE(), + type_=sa.DateTime(), + existing_nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('task', schema=None) as batch_op: + batch_op.alter_column('completed_at', + existing_type=sa.DateTime(), + type_=sa.DATE(), + existing_nullable=True) + + # ### end Alembic commands ### diff --git a/tests/test_wave_01.py b/tests/test_wave_01.py index ddfe3a8f6..85f856940 100644 --- a/tests/test_wave_01.py +++ b/tests/test_wave_01.py @@ -60,7 +60,7 @@ def test_get_task_not_found(client): # Assert assert response.status_code == 404 assert "message" in response_body - assert response_body == {"message": f"Task 1 not found"} + assert response_body == {"message": f"Invalid request: Task 1 not found"} # @pytest.mark.skip(reason="No way to test this feature yet") @@ -128,7 +128,7 @@ def test_update_task_not_found(client): # Assert assert response.status_code == 404 assert "message" in response_body - assert response_body == {"message": f"Task 1 not found"} + assert response_body == {"message": f"Invalid request: Task 1 not found"} # @pytest.mark.skip(reason="No way to test this feature yet") @@ -155,7 +155,7 @@ def test_delete_task_not_found(client): # Assert assert response.status_code == 404 assert "message" in response_body - assert response_body == {"message": f"Task 1 not found"} + assert response_body == {"message": f"Invalid request: Task 1 not found"} assert Task.query.all() == [] diff --git a/tests/test_wave_03.py b/tests/test_wave_03.py index 32d379822..da8e6ba1e 100644 --- a/tests/test_wave_03.py +++ b/tests/test_wave_03.py @@ -5,7 +5,7 @@ import pytest -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_mark_complete_on_incomplete_task(client, one_task): # Arrange """ @@ -42,7 +42,7 @@ def test_mark_complete_on_incomplete_task(client, one_task): assert Task.query.get(1).completed_at -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_mark_incomplete_on_complete_task(client, completed_task): # Act response = client.patch("/tasks/1/mark_incomplete") @@ -62,7 +62,7 @@ def test_mark_incomplete_on_complete_task(client, completed_task): assert Task.query.get(1).completed_at == None -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_mark_complete_on_completed_task(client, completed_task): # Arrange """ @@ -99,7 +99,7 @@ def test_mark_complete_on_completed_task(client, completed_task): assert Task.query.get(1).completed_at -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_mark_incomplete_on_incomplete_task(client, one_task): # Act response = client.patch("/tasks/1/mark_incomplete") From 56fe157c59c36f2aa94a4a279bfe25e67ea3365c Mon Sep 17 00:00:00 2001 From: Charday Neal Date: Tue, 5 Nov 2024 21:01:12 -0800 Subject: [PATCH 05/14] Implement Slack API to send message once task complete --- app/routes/route_utilities.py | 30 ++++++++++++++++++++++++++++++ app/routes/task_routes.py | 7 +++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 81bf3b824..287b07cc6 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -1,5 +1,14 @@ from flask import abort, make_response from app.db import db +import os, requests +from dotenv import load_dotenv + +load_dotenv() + +API_KEY = os.environ.get("API_KEY") +CHANNEL_ID = os.environ.get("CHANNEL_ID") + + def create_model(cls, model_data): try: @@ -30,4 +39,25 @@ def validate_model(cls, model_id): response = {"message": f"Invalid request: {cls.__name__} {model_id} not found"} abort(make_response(response, 404)) +def send_slack_message(message): + url = "https://slack.com/api/chat.postMessage" + + headers = { + "Authorization": f"Bearer {API_KEY}" + } + + request_body = { + "channel": CHANNEL_ID, + "text": message + } + + try: + response = requests.post(url=url, headers=headers, json=request_body) + + response.raise_for_status() + return response.json() + + except: + abort(make_response({"message": "Unknkown request"}, 400)) + \ No newline at end of file diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 480a74af5..a2021a511 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1,10 +1,11 @@ from flask import Blueprint, request from sqlalchemy import desc from datetime import datetime -from app.routes.route_utilities import create_model, validate_model +from app.routes.route_utilities import create_model, validate_model, send_slack_message from app.models.task import Task from app.db import db + tasks_bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") @tasks_bp.post("") @@ -52,7 +53,9 @@ def update_task_as_complete(task_id): db.session.commit() - # make call to Slack API, post message to workspace and channel + text_message = f"Someone just completed the task {task.title}" + send_slack_message(text_message) + return {"task": task.to_dict()}, 200 @tasks_bp.patch("//mark_incomplete") From c1985c6b1e1d2f9f86688541e4ee85a8ef8501b3 Mon Sep 17 00:00:00 2001 From: Charday Neal Date: Wed, 6 Nov 2024 06:57:25 -0800 Subject: [PATCH 06/14] Create Goal model and add goal endpoints Updated the goal model attributes with 'title' and migrated changes Create GET, POST, PUT, DELETE endpoints for Goal model Completed partial testing for endpoints --- app/__init__.py | 2 + app/models/goal.py | 13 +++++ app/routes/goal_routes.py | 54 ++++++++++++++++++- app/routes/route_utilities.py | 4 +- app/routes/task_routes.py | 4 +- .../versions/e1b19a6219cf_add_goal_model.py | 32 +++++++++++ tests/test_wave_05.py | 10 ++-- 7 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 migrations/versions/e1b19a6219cf_add_goal_model.py diff --git a/app/__init__.py b/app/__init__.py index 80dc0d60a..41bec4137 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -2,6 +2,7 @@ from .db import db, migrate from .models import task, goal from .routes.task_routes import tasks_bp +from .routes.goal_routes import bp as goals_bp import os def create_app(config=None): @@ -20,5 +21,6 @@ def create_app(config=None): # Register Blueprints here 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 44282656b..da1c14074 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -3,3 +3,16 @@ class Goal(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + title: Mapped[str] + + def to_dict(self): + goal_to_dict = { + "id": self.id, + "title": self.title + } + return goal_to_dict + + @classmethod + def from_dict(cls, goal_data): + goal_dict = cls(title=goal_data["title"]) + return goal_dict \ No newline at end of file diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 3aae38d49..1856d36c6 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -1 +1,53 @@ -from flask import Blueprint \ No newline at end of file +from flask import Blueprint, request, abort, make_response +from app.models.goal import Goal +from app.routes.route_utilities import create_model, validate_model +from app.db import db + +bp = Blueprint("goal_bp", __name__, url_prefix="/goals") + +@bp.post("") +def create_goal(): + response = create_model(Goal, request.get_json()) + return {"goal": response}, 201 + + +@bp.get("") +def get_all_goals(): + query = db.select(Goal).order_by(Goal.id) + goals = db.session.scalars(query) + + return [goal.to_dict() for goal in goals] + + +@bp.get("/") +def get_goal(goal_id): + goal = validate_model(Goal, goal_id) + return {"goal": goal.to_dict()} + + +@bp.put("/") +def update_goal(goal_id): + goal = validate_model(Goal, goal_id) + + request_body = request.get_json() + try: + goal.title = request_body["title"] + db.session.commit() + + except KeyError as error: + response = {"details": f"Invalid request: missing {error.args[0]}"} + abort(make_response(response, 400)) + + return {"goal": goal.to_dict()} + + +@bp.delete("/") +def delete_goal(goal_id): + goal = validate_model(Goal, goal_id) + + db.session.delete(goal) + db.session.commit() + + response = f"Goal {goal_id} {goal.title} successfully deleted" + + return {"details": response} \ No newline at end of file diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 287b07cc6..53027532c 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -8,8 +8,6 @@ API_KEY = os.environ.get("API_KEY") CHANNEL_ID = os.environ.get("CHANNEL_ID") - - def create_model(cls, model_data): try: model = cls.from_dict(model_data) @@ -21,7 +19,7 @@ def create_model(cls, model_data): db.session.add(model) db.session.commit() - return {"task": model.to_dict()}, 201 + return model.to_dict() def validate_model(cls, model_id): try: diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index a2021a511..8a7292dd4 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -11,7 +11,9 @@ @tasks_bp.post("") def create_task(): request_body = request.get_json() - return create_model(Task, request_body) + response = create_model(Task, request_body) + + return {"task": response}, 201 @tasks_bp.get("") def get_all_tasks(): diff --git a/migrations/versions/e1b19a6219cf_add_goal_model.py b/migrations/versions/e1b19a6219cf_add_goal_model.py new file mode 100644 index 000000000..718e400ad --- /dev/null +++ b/migrations/versions/e1b19a6219cf_add_goal_model.py @@ -0,0 +1,32 @@ +"""Add Goal model + +Revision ID: e1b19a6219cf +Revises: 8607dc03f9f9 +Create Date: 2024-11-06 06:10:46.839843 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e1b19a6219cf' +down_revision = '8607dc03f9f9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('goal', schema=None) as batch_op: + batch_op.add_column(sa.Column('title', sa.String(), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('goal', schema=None) as batch_op: + batch_op.drop_column('title') + + # ### end Alembic commands ### diff --git a/tests/test_wave_05.py b/tests/test_wave_05.py index aee7c52a1..26d37fc4f 100644 --- a/tests/test_wave_05.py +++ b/tests/test_wave_05.py @@ -1,7 +1,7 @@ import pytest -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_goals_no_saved_goals(client): # Act response = client.get("/goals") @@ -12,7 +12,7 @@ def test_get_goals_no_saved_goals(client): assert response_body == [] -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_goals_one_saved_goal(client, one_goal): # Act response = client.get("/goals") @@ -29,7 +29,7 @@ def test_get_goals_one_saved_goal(client, one_goal): ] -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_goal(client, one_goal): # Act response = client.get("/goals/1") @@ -61,7 +61,7 @@ def test_get_goal_not_found(client): # ---- Complete Test ---- -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_create_goal(client): # Act response = client.post("/goals", json={ @@ -144,7 +144,7 @@ def test_delete_goal_not_found(client): # ---- Complete Assertions Here ---- -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_create_goal_missing_title(client): # Act response = client.post("/goals", json={}) From 5d12513ff2ed268d7703e8e040c5bf16fcc9f315 Mon Sep 17 00:00:00 2001 From: Charday Neal Date: Wed, 6 Nov 2024 13:39:54 -0800 Subject: [PATCH 07/14] Test exceptions are being raised Implemented some asserts to a few tests to ensure our api responds appropriately to an invalid request and that exception are in fact raised. --- tests/test_wave_03.py | 45 +++++++------------------------------------ 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/tests/test_wave_03.py b/tests/test_wave_03.py index da8e6ba1e..a29eb5143 100644 --- a/tests/test_wave_03.py +++ b/tests/test_wave_03.py @@ -4,22 +4,9 @@ from app.models.task import Task import pytest - # @pytest.mark.skip(reason="No way to test this feature yet") def test_mark_complete_on_incomplete_task(client, one_task): # Arrange - """ - The future Wave 4 adds special functionality to this route, - so for this test, we need to set-up "mocking." - - Mocking will help our tests work in isolation, which is a - good thing! - - We need to mock any POST requests that may occur during this - test (due to Wave 4). - - There is no action needed here, the tests should work as-is. - """ with patch("requests.post") as mock_get: mock_get.return_value.status_code = 200 @@ -65,18 +52,6 @@ def test_mark_incomplete_on_complete_task(client, completed_task): # @pytest.mark.skip(reason="No way to test this feature yet") def test_mark_complete_on_completed_task(client, completed_task): # Arrange - """ - The future Wave 4 adds special functionality to this route, - so for this test, we need to set-up "mocking." - - Mocking will help our tests work in isolation, which is a - good thing! - - We need to mock any POST requests that may occur during this - test (due to Wave 4). - - There is no action needed here, the tests should work as-is. - """ with patch("requests.post") as mock_get: mock_get.return_value.status_code = 200 @@ -119,7 +94,7 @@ def test_mark_incomplete_on_incomplete_task(client, one_task): assert Task.query.get(1).completed_at == None -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_mark_complete_missing_task(client): # Act response = client.patch("/tasks/1/mark_complete") @@ -127,14 +102,11 @@ def test_mark_complete_missing_task(client): # Assert assert response.status_code == 404 + assert not "task" in response_body + assert response_body == {"message": f"Invalid request: Task 1 not found"} + - raise Exception("Complete test with assertion about response body") - # ***************************************************************** - # **Complete test with assertion about response body*************** - # ***************************************************************** - - -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_mark_incomplete_missing_task(client): # Act response = client.patch("/tasks/1/mark_incomplete") @@ -142,8 +114,5 @@ def test_mark_incomplete_missing_task(client): # Assert assert response.status_code == 404 - - raise Exception("Complete test with assertion about response body") - # ***************************************************************** - # **Complete test with assertion about response body*************** - # ***************************************************************** + assert not "task" in response_body + assert response_body == {"message": f"Invalid request: Task 1 not found"} \ No newline at end of file From 98079f2aee517ecae1e0ee92cd1bda4480c71acf Mon Sep 17 00:00:00 2001 From: Charday Neal Date: Wed, 6 Nov 2024 16:03:48 -0800 Subject: [PATCH 08/14] Complete test suite for Goal model endpoints Tests invalid requests made by user and ensure they're handle appropriately --- app/routes/goal_routes.py | 2 +- tests/test_wave_05.py | 75 ++++++++++++++++++--------------------- 2 files changed, 36 insertions(+), 41 deletions(-) diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 1856d36c6..7b26bb828 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -48,6 +48,6 @@ def delete_goal(goal_id): db.session.delete(goal) db.session.commit() - response = f"Goal {goal_id} {goal.title} successfully deleted" + response = f"Goal {goal_id} \"{goal.title}\" successfully deleted" return {"details": response} \ No newline at end of file diff --git a/tests/test_wave_05.py b/tests/test_wave_05.py index 26d37fc4f..9a7240b0a 100644 --- a/tests/test_wave_05.py +++ b/tests/test_wave_05.py @@ -1,4 +1,5 @@ import pytest +from app.models.goal import Goal # @pytest.mark.skip(reason="No way to test this feature yet") @@ -46,19 +47,16 @@ def test_get_goal(client, one_goal): } -@pytest.mark.skip(reason="test to be completed by student") +# @pytest.mark.skip(reason="test to be completed by student") def test_get_goal_not_found(client): - pass # Act response = client.get("/goals/1") response_body = response.get_json() - raise Exception("Complete test") - # Assert - # ---- Complete Test ---- - # assertion 1 goes here - # assertion 2 goes here - # ---- Complete Test ---- + assert response.status_code == 404 + assert "message" in response_body + assert response_body == {"message": "Invalid request: Goal 1 not found"} + # @pytest.mark.skip(reason="No way to test this feature yet") @@ -80,34 +78,35 @@ def test_create_goal(client): } -@pytest.mark.skip(reason="test to be completed by student") +# @pytest.mark.skip(reason="test to be completed by student") def test_update_goal(client, one_goal): - raise Exception("Complete test") # Act - # ---- Complete Act Here ---- + response = client.put("/goals/1", json={ + "title": "Grab broccoli on the way home" + }) + response_body = response.get_json() # Assert - # ---- Complete Assertions Here ---- - # assertion 1 goes here - # assertion 2 goes here - # assertion 3 goes here - # ---- Complete Assertions Here ---- + assert response.status_code == 200 + assert "goal" in response_body + assert response_body == {"goal": {"title": "Grab broccoli on the way home", "id": 1}} + goal = Goal.query.get(1) + assert goal.title == "Grab broccoli on the way home" -@pytest.mark.skip(reason="test to be completed by student") +# @pytest.mark.skip(reason="test to be completed by student") def test_update_goal_not_found(client): - raise Exception("Complete test") - # Act - # ---- Complete Act Here ---- + response = client.put("/goals/1", json={ + "title": "Send invitations" + }) + response_body = response.get_json() - # Assert - # ---- Complete Assertions Here ---- - # assertion 1 goes here - # assertion 2 goes here - # ---- Complete Assertions Here ---- + assert response.status_code == 404 + assert "message" in response_body + assert response_body == {"message": "Invalid request: Goal 1 not found"} -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_delete_goal(client, one_goal): # Act response = client.delete("/goals/1") @@ -122,26 +121,22 @@ def test_delete_goal(client, one_goal): # Check that the goal was deleted response = client.get("/goals/1") - assert response.status_code == 404 + response_body = response.get_json() - raise Exception("Complete test with assertion about response body") - # ***************************************************************** - # **Complete test with assertion about response body*************** - # ***************************************************************** + assert response.status_code == 404 + assert "message" in response_body + assert response_body == {"message": "Invalid request: Goal 1 not found"} -@pytest.mark.skip(reason="test to be completed by student") +# @pytest.mark.skip(reason="test to be completed by student") def test_delete_goal_not_found(client): - raise Exception("Complete test") + response = client.delete("/goals/1") + response_body = response.get_json() - # Act - # ---- Complete Act Here ---- + assert response.status_code == 404 + assert "message" in response_body + assert response_body == {"message": "Invalid request: Goal 1 not found"} - # Assert - # ---- Complete Assertions Here ---- - # assertion 1 goes here - # assertion 2 goes here - # ---- Complete Assertions Here ---- # @pytest.mark.skip(reason="No way to test this feature yet") From c2f6bfd68b1b2e951ec4702922e5b8b0e7257a0b Mon Sep 17 00:00:00 2001 From: Charday Neal Date: Wed, 6 Nov 2024 18:42:13 -0800 Subject: [PATCH 09/14] Establish relationship between models Create a one-to-many relationship with Goal and Task models Create endpoints that allow users to view tasks by goal and add task ids to a specified goal --- app/models/goal.py | 5 +-- app/models/task.py | 11 ++++-- app/routes/goal_routes.py | 30 ++++++++++++++++ ...add_relationship_between_task_and_goal_.py | 34 +++++++++++++++++++ tests/test_wave_06.py | 20 +++++------ 5 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 migrations/versions/90d5f7e5da48_add_relationship_between_task_and_goal_.py diff --git a/app/models/goal.py b/app/models/goal.py index da1c14074..2891f7892 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -1,9 +1,10 @@ -from sqlalchemy.orm import Mapped, mapped_column -from ..db import db +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.db import db class Goal(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] + tasks: Mapped[list["Task"]] = relationship(back_populates="goal") def to_dict(self): goal_to_dict = { diff --git a/app/models/task.py b/app/models/task.py index 8b0eade63..a6f805191 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1,5 +1,6 @@ from datetime import datetime -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import ForeignKey from typing import Optional from ..db import db @@ -7,7 +8,9 @@ class Task(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] description: Mapped[str] - completed_at: Mapped[Optional[datetime]] + completed_at: Mapped[Optional[datetime]] + goal_id: Mapped[Optional[int]] = mapped_column(ForeignKey("goal.id")) + goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") def to_dict(self): task_to_dict = { @@ -16,6 +19,10 @@ def to_dict(self): "description": self.description, "is_complete": True if self.completed_at else False } + + if self.goal_id: + task_to_dict["goal_id"] = self.goal_id + return task_to_dict @classmethod diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 7b26bb828..8ebfc15cf 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -1,5 +1,6 @@ from flask import Blueprint, request, abort, make_response from app.models.goal import Goal +from app.models.task import Task from app.routes.route_utilities import create_model, validate_model from app.db import db @@ -11,6 +12,27 @@ def create_goal(): return {"goal": response}, 201 +@bp.post("//tasks") +def create_task_ids_by_goal(goal_id): + request_body = request.get_json() + goal = validate_model(Goal, goal_id) + + try: + task_ids = request_body["task_ids"] + + for task_id in task_ids: + task = validate_model(Task, task_id) + goal.tasks.append(task) + + db.session.commit() + + except KeyError as error: + response = {"details": f"Invalid request: missing {error.args[0]}"} + abort(make_response(response, 400)) + + return {"id": goal.id, "task_ids": task_ids} + + @bp.get("") def get_all_goals(): query = db.select(Goal).order_by(Goal.id) @@ -25,6 +47,14 @@ def get_goal(goal_id): return {"goal": goal.to_dict()} +@bp.get("//tasks") +def get_tasks_by_goal(goal_id): + goal = validate_model(Goal, goal_id) + goal_dict = goal.to_dict() + goal_dict["tasks"] = [task.to_dict() for task in goal.tasks] + return goal_dict + + @bp.put("/") def update_goal(goal_id): goal = validate_model(Goal, goal_id) diff --git a/migrations/versions/90d5f7e5da48_add_relationship_between_task_and_goal_.py b/migrations/versions/90d5f7e5da48_add_relationship_between_task_and_goal_.py new file mode 100644 index 000000000..a829e18c4 --- /dev/null +++ b/migrations/versions/90d5f7e5da48_add_relationship_between_task_and_goal_.py @@ -0,0 +1,34 @@ +"""Add relationship between task and goal models + +Revision ID: 90d5f7e5da48 +Revises: e1b19a6219cf +Create Date: 2024-11-06 17:40:55.357341 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '90d5f7e5da48' +down_revision = 'e1b19a6219cf' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('task', schema=None) as batch_op: + batch_op.add_column(sa.Column('goal_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(None, 'goal', ['goal_id'], ['id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('task', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_column('goal_id') + + # ### end Alembic commands ### diff --git a/tests/test_wave_06.py b/tests/test_wave_06.py index 8afa4325e..1813e0b8b 100644 --- a/tests/test_wave_06.py +++ b/tests/test_wave_06.py @@ -2,7 +2,7 @@ import pytest -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_post_task_ids_to_goal(client, one_goal, three_tasks): # Act response = client.post("/goals/1/tasks", json={ @@ -23,7 +23,7 @@ def test_post_task_ids_to_goal(client, one_goal, three_tasks): assert len(Goal.query.get(1).tasks) == 3 -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_post_task_ids_to_goal_already_with_goals(client, one_task_belongs_to_one_goal, three_tasks): # Act response = client.post("/goals/1/tasks", json={ @@ -42,7 +42,7 @@ def test_post_task_ids_to_goal_already_with_goals(client, one_task_belongs_to_on assert len(Goal.query.get(1).tasks) == 2 -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_tasks_for_specific_goal_no_goal(client): # Act response = client.get("/goals/1/tasks") @@ -50,14 +50,11 @@ def test_get_tasks_for_specific_goal_no_goal(client): # Assert assert response.status_code == 404 + assert "message" in response_body + assert response_body == {"message": "Invalid request: Goal 1 not found"} - raise Exception("Complete test with assertion about response body") - # ***************************************************************** - # **Complete test with assertion about response body*************** - # ***************************************************************** - -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_tasks_for_specific_goal_no_tasks(client, one_goal): # Act response = client.get("/goals/1/tasks") @@ -74,11 +71,12 @@ def test_get_tasks_for_specific_goal_no_tasks(client, one_goal): } -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_tasks_for_specific_goal(client, one_task_belongs_to_one_goal): # Act response = client.get("/goals/1/tasks") response_body = response.get_json() + print(response_body) # Assert assert response.status_code == 200 @@ -99,7 +97,7 @@ def test_get_tasks_for_specific_goal(client, one_task_belongs_to_one_goal): } -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_task_includes_goal_id(client, one_task_belongs_to_one_goal): response = client.get("/tasks/1") response_body = response.get_json() From d79b8a7c595da738d365660f9c0b1e7a10224317 Mon Sep 17 00:00:00 2001 From: Charday Neal Date: Wed, 6 Nov 2024 19:27:42 -0800 Subject: [PATCH 10/14] Refactor code for spacing consistency Refactor code to ensure consistency throughout app Refactor Goal models methods to_dict() and from_dict() --- app/__init__.py | 6 ++-- app/models/goal.py | 11 ++----- app/routes/goal_routes.py | 7 +++-- app/routes/route_utilities.py | 8 +++-- app/routes/task_routes.py | 59 ++++++++++++++++++----------------- tests/test_wave_01.py | 34 ++------------------ tests/test_wave_02.py | 4 --- tests/test_wave_03.py | 14 --------- tests/test_wave_05.py | 21 ++----------- tests/test_wave_06.py | 11 ------- 10 files changed, 49 insertions(+), 126 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 41bec4137..d05ec35cc 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,7 +1,7 @@ from flask import Flask from .db import db, migrate from .models import task, goal -from .routes.task_routes import tasks_bp +from .routes.task_routes import bp as tasks_bp from .routes.goal_routes import bp as goals_bp import os @@ -12,8 +12,6 @@ def create_app(config=None): app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('SQLALCHEMY_DATABASE_URI') if config: - # Merge `config` into the app's configuration - # to override the app's default settings for testing app.config.update(config) db.init_app(app) @@ -23,4 +21,4 @@ def create_app(config=None): app.register_blueprint(tasks_bp) app.register_blueprint(goals_bp) - return app + return app \ No newline at end of file diff --git a/app/models/goal.py b/app/models/goal.py index 2891f7892..883b9a7fe 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -7,13 +7,8 @@ class Goal(db.Model): tasks: Mapped[list["Task"]] = relationship(back_populates="goal") def to_dict(self): - goal_to_dict = { - "id": self.id, - "title": self.title - } - return goal_to_dict - + return {"id": self.id, "title": self.title} + @classmethod def from_dict(cls, goal_data): - goal_dict = cls(title=goal_data["title"]) - return goal_dict \ No newline at end of file + return cls(title=goal_data["title"]) diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 8ebfc15cf..c5d65dafa 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -19,7 +19,6 @@ def create_task_ids_by_goal(goal_id): try: task_ids = request_body["task_ids"] - for task_id in task_ids: task = validate_model(Task, task_id) goal.tasks.append(task) @@ -34,7 +33,7 @@ def create_task_ids_by_goal(goal_id): @bp.get("") -def get_all_goals(): +def get_goals(): query = db.select(Goal).order_by(Goal.id) goals = db.session.scalars(query) @@ -42,7 +41,7 @@ def get_all_goals(): @bp.get("/") -def get_goal(goal_id): +def get_goal_by_id(goal_id): goal = validate_model(Goal, goal_id) return {"goal": goal.to_dict()} @@ -50,8 +49,10 @@ def get_goal(goal_id): @bp.get("//tasks") def get_tasks_by_goal(goal_id): goal = validate_model(Goal, goal_id) + goal_dict = goal.to_dict() goal_dict["tasks"] = [task.to_dict() for task in goal.tasks] + return goal_dict diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 53027532c..2d100c9b0 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -21,9 +21,11 @@ def create_model(cls, model_data): return model.to_dict() + def validate_model(cls, model_id): try: model_id = int(model_id) + except ValueError: response = {"message": f"Invalid request: {cls.__name__} id {model_id} invalid"} abort(make_response(response, 400)) @@ -37,7 +39,8 @@ def validate_model(cls, model_id): response = {"message": f"Invalid request: {cls.__name__} {model_id} not found"} abort(make_response(response, 404)) -def send_slack_message(message): + +def send_slack_message(task_title): url = "https://slack.com/api/chat.postMessage" headers = { @@ -46,12 +49,11 @@ def send_slack_message(message): request_body = { "channel": CHANNEL_ID, - "text": message + "text": f"Someone just completed the task {task_title}" } try: response = requests.post(url=url, headers=headers, json=request_body) - response.raise_for_status() return response.json() diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 8a7292dd4..e7fd3a136 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -6,37 +6,36 @@ from app.db import db -tasks_bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") +bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") -@tasks_bp.post("") +@bp.post("") def create_task(): - request_body = request.get_json() - response = create_model(Task, request_body) - + response = create_model(Task, request.get_json()) return {"task": response}, 201 -@tasks_bp.get("") -def get_all_tasks(): + +@bp.get("") +def get_tasks(): sort_param = request.args.get("sort") query = db.select(Task) - - if sort_param: - if sort_param == "desc": - query = query.order_by(desc(Task.title)) - else: - query = query.order_by(Task.title) + + if sort_param == "desc": + query = query.order_by(desc(Task.title)) + else: + query = query.order_by(Task.title) tasks = db.session.scalars(query) - return [task.to_dict() for task in tasks], 200 + return [task.to_dict() for task in tasks] + -@tasks_bp.get("/") -def get_saved_task_by_id(task_id): +@bp.get("/") +def get_task_by_id(task_id): task = validate_model(Task, task_id) + return {"task": task.to_dict()} - return {"task": task.to_dict()}, 200 -@tasks_bp.put("/") +@bp.put("/") def update_task(task_id): task = validate_model(Task, task_id) request_body = request.get_json() @@ -46,34 +45,36 @@ def update_task(task_id): db.session.commit() - return {"task": task.to_dict()}, 200 + return {"task": task.to_dict()} + -@tasks_bp.patch("//mark_complete") +@bp.patch("//mark_complete") def update_task_as_complete(task_id): task = validate_model(Task, task_id) - task.completed_at = datetime.now() + task.completed_at = datetime.now() db.session.commit() - text_message = f"Someone just completed the task {task.title}" - send_slack_message(text_message) + send_slack_message(task.title) + + return {"task": task.to_dict()} - return {"task": task.to_dict()}, 200 -@tasks_bp.patch("//mark_incomplete") +@bp.patch("//mark_incomplete") def update_task_as_incomplete(task_id): task = validate_model(Task, task_id) - task.completed_at = None + task.completed_at = None db.session.commit() - return {"task": task.to_dict()}, 200 + return {"task": task.to_dict()} + -@tasks_bp.delete("/") +@bp.delete("/") def delete_task(task_id): task = validate_model(Task, task_id) db.session.delete(task) db.session.commit() - return {"details": f"Task {task_id} \"{task.title}\" successfully deleted"}, 200 \ No newline at end of file + return {"details": f"Task {task_id} \"{task.title}\" successfully deleted"} \ No newline at end of file diff --git a/tests/test_wave_01.py b/tests/test_wave_01.py index 85f856940..29ea45e16 100644 --- a/tests/test_wave_01.py +++ b/tests/test_wave_01.py @@ -4,22 +4,18 @@ # @pytest.mark.skip(reason="No way to test this feature yet") def test_get_tasks_no_saved_tasks(client): - # Act response = client.get("/tasks") response_body = response.get_json() - # Assert assert response.status_code == 200 assert response_body == [] # @pytest.mark.skip(reason="No way to test this feature yet") def test_get_tasks_one_saved_tasks(client, one_task): - # Act response = client.get("/tasks") response_body = response.get_json() - # Assert assert response.status_code == 200 assert len(response_body) == 1 assert response_body == [ @@ -34,11 +30,9 @@ def test_get_tasks_one_saved_tasks(client, one_task): # @pytest.mark.skip(reason="No way to test this feature yet") def test_get_task(client, one_task): - # Act response = client.get("/tasks/1") response_body = response.get_json() - # Assert assert response.status_code == 200 assert "task" in response_body assert response_body == { @@ -53,11 +47,9 @@ def test_get_task(client, one_task): # @pytest.mark.skip(reason="No way to test this feature yet") def test_get_task_not_found(client): - # Act response = client.get("/tasks/1") response_body = response.get_json() - # Assert assert response.status_code == 404 assert "message" in response_body assert response_body == {"message": f"Invalid request: Task 1 not found"} @@ -65,14 +57,12 @@ def test_get_task_not_found(client): # @pytest.mark.skip(reason="No way to test this feature yet") def test_create_task(client): - # Act response = client.post("/tasks", json={ "title": "A Brand New Task", "description": "Test Description", }) response_body = response.get_json() - # Assert assert response.status_code == 201 assert "task" in response_body assert response_body == { @@ -92,14 +82,12 @@ def test_create_task(client): # @pytest.mark.skip(reason="No way to test this feature yet") def test_update_task(client, one_task): - # Act response = client.put("/tasks/1", json={ "title": "Updated Task Title", "description": "Updated Test Description", }) response_body = response.get_json() - # Assert assert response.status_code == 200 assert "task" in response_body assert response_body == { @@ -118,14 +106,12 @@ def test_update_task(client, one_task): # @pytest.mark.skip(reason="No way to test this feature yet") def test_update_task_not_found(client): - # Act response = client.put("/tasks/1", json={ "title": "Updated Task Title", "description": "Updated Test Description", }) response_body = response.get_json() - # Assert assert response.status_code == 404 assert "message" in response_body assert response_body == {"message": f"Invalid request: Task 1 not found"} @@ -133,26 +119,20 @@ def test_update_task_not_found(client): # @pytest.mark.skip(reason="No way to test this feature yet") def test_delete_task(client, one_task): - # Act response = client.delete("/tasks/1") response_body = response.get_json() - # Assert assert response.status_code == 200 assert "details" in response_body - assert response_body == { - "details": 'Task 1 "Go on my daily walk 🏞" successfully deleted' - } + assert response_body == {"details": 'Task 1 "Go on my daily walk 🏞" successfully deleted'} assert Task.query.get(1) == None # @pytest.mark.skip(reason="No way to test this feature yet") def test_delete_task_not_found(client): - # Act response = client.delete("/tasks/1") response_body = response.get_json() - # Assert assert response.status_code == 404 assert "message" in response_body assert response_body == {"message": f"Invalid request: Task 1 not found"} @@ -161,33 +141,25 @@ def test_delete_task_not_found(client): # @pytest.mark.skip(reason="No way to test this feature yet") def test_create_task_must_contain_title(client): - # Act response = client.post("/tasks", json={ "description": "Test Description" }) response_body = response.get_json() - # Assert assert response.status_code == 400 assert "details" in response_body - assert response_body == { - "details": "Invalid data" - } + assert response_body == {"details": "Invalid data"} assert Task.query.all() == [] # @pytest.mark.skip(reason="No way to test this feature yet") def test_create_task_must_contain_description(client): - # Act response = client.post("/tasks", json={ "title": "A Brand New Task" }) response_body = response.get_json() - # Assert assert response.status_code == 400 assert "details" in response_body - assert response_body == { - "details": "Invalid data" - } + assert response_body == {"details": "Invalid data"} assert Task.query.all() == [] diff --git a/tests/test_wave_02.py b/tests/test_wave_02.py index 651e3aebd..d41f0297d 100644 --- a/tests/test_wave_02.py +++ b/tests/test_wave_02.py @@ -3,11 +3,9 @@ # @pytest.mark.skip(reason="No way to test this feature yet") def test_get_tasks_sorted_asc(client, three_tasks): - # Act response = client.get("/tasks?sort=asc") response_body = response.get_json() - # Assert assert response.status_code == 200 assert len(response_body) == 3 assert response_body == [ @@ -31,11 +29,9 @@ def test_get_tasks_sorted_asc(client, three_tasks): # @pytest.mark.skip(reason="No way to test this feature yet") def test_get_tasks_sorted_desc(client, three_tasks): - # Act response = client.get("/tasks?sort=desc") response_body = response.get_json() - # Assert assert response.status_code == 200 assert len(response_body) == 3 assert response_body == [ diff --git a/tests/test_wave_03.py b/tests/test_wave_03.py index a29eb5143..fe749c26c 100644 --- a/tests/test_wave_03.py +++ b/tests/test_wave_03.py @@ -6,15 +6,12 @@ # @pytest.mark.skip(reason="No way to test this feature yet") def test_mark_complete_on_incomplete_task(client, one_task): - # Arrange with patch("requests.post") as mock_get: mock_get.return_value.status_code = 200 - # Act response = client.patch("/tasks/1/mark_complete") response_body = response.get_json() - # Assert assert response.status_code == 200 assert "task" in response_body assert response_body["task"]["is_complete"] == True @@ -31,11 +28,9 @@ def test_mark_complete_on_incomplete_task(client, one_task): # @pytest.mark.skip(reason="No way to test this feature yet") def test_mark_incomplete_on_complete_task(client, completed_task): - # Act response = client.patch("/tasks/1/mark_incomplete") response_body = response.get_json() - # Assert assert response.status_code == 200 assert response_body["task"]["is_complete"] == False assert response_body == { @@ -51,15 +46,12 @@ def test_mark_incomplete_on_complete_task(client, completed_task): # @pytest.mark.skip(reason="No way to test this feature yet") def test_mark_complete_on_completed_task(client, completed_task): - # Arrange with patch("requests.post") as mock_get: mock_get.return_value.status_code = 200 - # Act response = client.patch("/tasks/1/mark_complete") response_body = response.get_json() - # Assert assert response.status_code == 200 assert "task" in response_body assert response_body["task"]["is_complete"] == True @@ -76,11 +68,9 @@ def test_mark_complete_on_completed_task(client, completed_task): # @pytest.mark.skip(reason="No way to test this feature yet") def test_mark_incomplete_on_incomplete_task(client, one_task): - # Act response = client.patch("/tasks/1/mark_incomplete") response_body = response.get_json() - # Assert assert response.status_code == 200 assert response_body["task"]["is_complete"] == False assert response_body == { @@ -96,11 +86,9 @@ def test_mark_incomplete_on_incomplete_task(client, one_task): # @pytest.mark.skip(reason="No way to test this feature yet") def test_mark_complete_missing_task(client): - # Act response = client.patch("/tasks/1/mark_complete") response_body = response.get_json() - # Assert assert response.status_code == 404 assert not "task" in response_body assert response_body == {"message": f"Invalid request: Task 1 not found"} @@ -108,11 +96,9 @@ def test_mark_complete_missing_task(client): # @pytest.mark.skip(reason="No way to test this feature yet") def test_mark_incomplete_missing_task(client): - # Act response = client.patch("/tasks/1/mark_incomplete") response_body = response.get_json() - # Assert assert response.status_code == 404 assert not "task" in response_body assert response_body == {"message": f"Invalid request: Task 1 not found"} \ No newline at end of file diff --git a/tests/test_wave_05.py b/tests/test_wave_05.py index 9a7240b0a..4f95e77ea 100644 --- a/tests/test_wave_05.py +++ b/tests/test_wave_05.py @@ -4,22 +4,18 @@ # @pytest.mark.skip(reason="No way to test this feature yet") def test_get_goals_no_saved_goals(client): - # Act response = client.get("/goals") response_body = response.get_json() - # Assert assert response.status_code == 200 assert response_body == [] # @pytest.mark.skip(reason="No way to test this feature yet") def test_get_goals_one_saved_goal(client, one_goal): - # Act response = client.get("/goals") response_body = response.get_json() - # Assert assert response.status_code == 200 assert len(response_body) == 1 assert response_body == [ @@ -32,11 +28,9 @@ def test_get_goals_one_saved_goal(client, one_goal): # @pytest.mark.skip(reason="No way to test this feature yet") def test_get_goal(client, one_goal): - # Act response = client.get("/goals/1") response_body = response.get_json() - # Assert assert response.status_code == 200 assert "goal" in response_body assert response_body == { @@ -49,7 +43,6 @@ def test_get_goal(client, one_goal): # @pytest.mark.skip(reason="test to be completed by student") def test_get_goal_not_found(client): - # Act response = client.get("/goals/1") response_body = response.get_json() @@ -61,7 +54,6 @@ def test_get_goal_not_found(client): # @pytest.mark.skip(reason="No way to test this feature yet") def test_create_goal(client): - # Act response = client.post("/goals", json={ "title": "My New Goal" }) @@ -86,7 +78,6 @@ def test_update_goal(client, one_goal): }) response_body = response.get_json() - # Assert assert response.status_code == 200 assert "goal" in response_body assert response_body == {"goal": {"title": "Grab broccoli on the way home", "id": 1}} @@ -108,16 +99,12 @@ def test_update_goal_not_found(client): # @pytest.mark.skip(reason="No way to test this feature yet") def test_delete_goal(client, one_goal): - # Act response = client.delete("/goals/1") response_body = response.get_json() - # Assert assert response.status_code == 200 assert "details" in response_body - assert response_body == { - "details": 'Goal 1 "Build a habit of going outside daily" successfully deleted' - } + assert response_body == {"details": 'Goal 1 "Build a habit of going outside daily" successfully deleted'} # Check that the goal was deleted response = client.get("/goals/1") @@ -141,12 +128,8 @@ def test_delete_goal_not_found(client): # @pytest.mark.skip(reason="No way to test this feature yet") def test_create_goal_missing_title(client): - # Act response = client.post("/goals", json={}) response_body = response.get_json() - # Assert assert response.status_code == 400 - assert response_body == { - "details": "Invalid data" - } + assert response_body == {"details": "Invalid data"} diff --git a/tests/test_wave_06.py b/tests/test_wave_06.py index 1813e0b8b..aebaf99ea 100644 --- a/tests/test_wave_06.py +++ b/tests/test_wave_06.py @@ -4,13 +4,11 @@ # @pytest.mark.skip(reason="No way to test this feature yet") def test_post_task_ids_to_goal(client, one_goal, three_tasks): - # Act response = client.post("/goals/1/tasks", json={ "task_ids": [1, 2, 3] }) response_body = response.get_json() - # Assert assert response.status_code == 200 assert "id" in response_body assert "task_ids" in response_body @@ -25,13 +23,11 @@ def test_post_task_ids_to_goal(client, one_goal, three_tasks): # @pytest.mark.skip(reason="No way to test this feature yet") def test_post_task_ids_to_goal_already_with_goals(client, one_task_belongs_to_one_goal, three_tasks): - # Act response = client.post("/goals/1/tasks", json={ "task_ids": [1, 4] }) response_body = response.get_json() - # Assert assert response.status_code == 200 assert "id" in response_body assert "task_ids" in response_body @@ -44,11 +40,9 @@ def test_post_task_ids_to_goal_already_with_goals(client, one_task_belongs_to_on # @pytest.mark.skip(reason="No way to test this feature yet") def test_get_tasks_for_specific_goal_no_goal(client): - # Act response = client.get("/goals/1/tasks") response_body = response.get_json() - # Assert assert response.status_code == 404 assert "message" in response_body assert response_body == {"message": "Invalid request: Goal 1 not found"} @@ -56,11 +50,9 @@ def test_get_tasks_for_specific_goal_no_goal(client): # @pytest.mark.skip(reason="No way to test this feature yet") def test_get_tasks_for_specific_goal_no_tasks(client, one_goal): - # Act response = client.get("/goals/1/tasks") response_body = response.get_json() - # Assert assert response.status_code == 200 assert "tasks" in response_body assert len(response_body["tasks"]) == 0 @@ -73,12 +65,9 @@ def test_get_tasks_for_specific_goal_no_tasks(client, one_goal): # @pytest.mark.skip(reason="No way to test this feature yet") def test_get_tasks_for_specific_goal(client, one_task_belongs_to_one_goal): - # Act response = client.get("/goals/1/tasks") response_body = response.get_json() - print(response_body) - # Assert assert response.status_code == 200 assert "tasks" in response_body assert len(response_body["tasks"]) == 1 From a37a3376b800b1112569ed49681d02d7e2ba8a26 Mon Sep 17 00:00:00 2001 From: Charday Neal Date: Thu, 7 Nov 2024 13:45:56 -0800 Subject: [PATCH 11/14] Refactor code and fix test suite warnings Code used legacy code and updated with session.get() preferred method --- app/routes/goal_routes.py | 8 ++++---- app/routes/route_utilities.py | 7 +------ app/routes/task_routes.py | 2 -- tests/test_wave_01.py | 7 ++++--- tests/test_wave_03.py | 9 +++++---- tests/test_wave_05.py | 3 ++- tests/test_wave_06.py | 5 +++-- 7 files changed, 19 insertions(+), 22 deletions(-) diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index c5d65dafa..2619da5ee 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -13,7 +13,7 @@ def create_goal(): @bp.post("//tasks") -def create_task_ids_by_goal(goal_id): +def add_tasks_to_goal(goal_id): request_body = request.get_json() goal = validate_model(Goal, goal_id) @@ -33,7 +33,7 @@ def create_task_ids_by_goal(goal_id): @bp.get("") -def get_goals(): +def view_goals(): query = db.select(Goal).order_by(Goal.id) goals = db.session.scalars(query) @@ -41,13 +41,13 @@ def get_goals(): @bp.get("/") -def get_goal_by_id(goal_id): +def view_goal(goal_id): goal = validate_model(Goal, goal_id) return {"goal": goal.to_dict()} @bp.get("//tasks") -def get_tasks_by_goal(goal_id): +def view_tasks_by_goal(goal_id): goal = validate_model(Goal, goal_id) goal_dict = goal.to_dict() diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 2d100c9b0..a88d2f545 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -32,7 +32,6 @@ def validate_model(cls, model_id): query = db.select(cls).where(cls.id == model_id) model = db.session.scalar(query) - if model: return model @@ -42,11 +41,7 @@ def validate_model(cls, model_id): def send_slack_message(task_title): url = "https://slack.com/api/chat.postMessage" - - headers = { - "Authorization": f"Bearer {API_KEY}" - } - + headers = {"Authorization": f"Bearer {API_KEY}"} request_body = { "channel": CHANNEL_ID, "text": f"Someone just completed the task {task_title}" diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index e7fd3a136..64e862a1f 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -51,7 +51,6 @@ def update_task(task_id): @bp.patch("//mark_complete") def update_task_as_complete(task_id): task = validate_model(Task, task_id) - task.completed_at = datetime.now() db.session.commit() @@ -63,7 +62,6 @@ def update_task_as_complete(task_id): @bp.patch("//mark_incomplete") def update_task_as_incomplete(task_id): task = validate_model(Task, task_id) - task.completed_at = None db.session.commit() diff --git a/tests/test_wave_01.py b/tests/test_wave_01.py index 29ea45e16..6e658e266 100644 --- a/tests/test_wave_01.py +++ b/tests/test_wave_01.py @@ -1,4 +1,5 @@ from app.models.task import Task +from app.db import db import pytest @@ -73,7 +74,7 @@ def test_create_task(client): "is_complete": False } } - new_task = Task.query.get(1) + new_task = db.session.get(Task,1) assert new_task assert new_task.title == "A Brand New Task" assert new_task.description == "Test Description" @@ -98,7 +99,7 @@ def test_update_task(client, one_task): "is_complete": False } } - task = Task.query.get(1) + task = db.session.get(Task, 1) assert task.title == "Updated Task Title" assert task.description == "Updated Test Description" assert task.completed_at == None @@ -125,7 +126,7 @@ def test_delete_task(client, one_task): assert response.status_code == 200 assert "details" in response_body assert response_body == {"details": 'Task 1 "Go on my daily walk 🏞" successfully deleted'} - assert Task.query.get(1) == None + assert db.session.get(Task, 1) == None # @pytest.mark.skip(reason="No way to test this feature yet") diff --git a/tests/test_wave_03.py b/tests/test_wave_03.py index fe749c26c..ee6f00dd9 100644 --- a/tests/test_wave_03.py +++ b/tests/test_wave_03.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, patch from datetime import datetime from app.models.task import Task +from app.db import db import pytest # @pytest.mark.skip(reason="No way to test this feature yet") @@ -23,7 +24,7 @@ def test_mark_complete_on_incomplete_task(client, one_task): "is_complete": True } } - assert Task.query.get(1).completed_at + assert db.session.get(Task, 1).completed_at # @pytest.mark.skip(reason="No way to test this feature yet") @@ -41,7 +42,7 @@ def test_mark_incomplete_on_complete_task(client, completed_task): "is_complete": False } } - assert Task.query.get(1).completed_at == None + assert db.session.get(Task, 1).completed_at == None # @pytest.mark.skip(reason="No way to test this feature yet") @@ -63,7 +64,7 @@ def test_mark_complete_on_completed_task(client, completed_task): "is_complete": True } } - assert Task.query.get(1).completed_at + assert db.session.get(Task, 1).completed_at # @pytest.mark.skip(reason="No way to test this feature yet") @@ -81,7 +82,7 @@ def test_mark_incomplete_on_incomplete_task(client, one_task): "is_complete": False } } - assert Task.query.get(1).completed_at == None + assert db.session.get(Task, 1).completed_at == None # @pytest.mark.skip(reason="No way to test this feature yet") diff --git a/tests/test_wave_05.py b/tests/test_wave_05.py index 4f95e77ea..aeaefc1fd 100644 --- a/tests/test_wave_05.py +++ b/tests/test_wave_05.py @@ -1,5 +1,6 @@ import pytest from app.models.goal import Goal +from app.db import db # @pytest.mark.skip(reason="No way to test this feature yet") @@ -81,7 +82,7 @@ def test_update_goal(client, one_goal): assert response.status_code == 200 assert "goal" in response_body assert response_body == {"goal": {"title": "Grab broccoli on the way home", "id": 1}} - goal = Goal.query.get(1) + goal = db.session.get(Goal, 1) assert goal.title == "Grab broccoli on the way home" diff --git a/tests/test_wave_06.py b/tests/test_wave_06.py index aebaf99ea..1120e8a28 100644 --- a/tests/test_wave_06.py +++ b/tests/test_wave_06.py @@ -1,4 +1,5 @@ from app.models.goal import Goal +from app.db import db import pytest @@ -18,7 +19,7 @@ def test_post_task_ids_to_goal(client, one_goal, three_tasks): } # Check that Goal was updated in the db - assert len(Goal.query.get(1).tasks) == 3 + assert len(db.session.get(Goal, 1).tasks) == 3 # @pytest.mark.skip(reason="No way to test this feature yet") @@ -35,7 +36,7 @@ def test_post_task_ids_to_goal_already_with_goals(client, one_task_belongs_to_on "id": 1, "task_ids": [1, 4] } - assert len(Goal.query.get(1).tasks) == 2 + assert len(db.session.get(Goal, 1).tasks) == 2 # @pytest.mark.skip(reason="No way to test this feature yet") From 5c9cde787afa7a869ca39d195292a925e607909e Mon Sep 17 00:00:00 2001 From: Charday Neal Date: Thu, 7 Nov 2024 14:11:28 -0800 Subject: [PATCH 12/14] Correct imports in app init.py file --- app/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/__init__.py b/app/__init__.py index d05ec35cc..ccd78b165 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,6 +1,7 @@ from flask import Flask from .db import db, migrate -from .models import task, goal +from .models.task import Task +from .models.goal import Goal from .routes.task_routes import bp as tasks_bp from .routes.goal_routes import bp as goals_bp import os From 1a517b5a8310f52c9c3f7440b7b4e3afa76882fd Mon Sep 17 00:00:00 2001 From: Charday Neal Date: Sun, 22 Dec 2024 11:51:51 -0800 Subject: [PATCH 13/14] Update task_routes.py --- app/routes/task_routes.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 64e862a1f..ed14a517b 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -19,10 +19,12 @@ def get_tasks(): sort_param = request.args.get("sort") query = db.select(Task) - if sort_param == "desc": - query = query.order_by(desc(Task.title)) - else: - query = query.order_by(Task.title) + sort_method = request.args.get('sort') + + if sort_method and sort_method == "asc": + query = query.order_by(Task.id.asc()) + if sort_method and sort_method == "desc": + query = query.order_by(Task.id.desc()) tasks = db.session.scalars(query) @@ -75,4 +77,4 @@ def delete_task(task_id): db.session.delete(task) db.session.commit() - return {"details": f"Task {task_id} \"{task.title}\" successfully deleted"} \ No newline at end of file + return {"details": f"Task {task_id} \"{task.title}\" successfully deleted"} From 6079c73b63116aa9cd37362b3bac66c1f8bbab0d Mon Sep 17 00:00:00 2001 From: Charday Neal Date: Sun, 22 Dec 2024 12:01:42 -0800 Subject: [PATCH 14/14] Update task_routes.py --- app/routes/task_routes.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index ed14a517b..4ef0f8b4a 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -18,13 +18,11 @@ def create_task(): def get_tasks(): sort_param = request.args.get("sort") query = db.select(Task) - - sort_method = request.args.get('sort') - - if sort_method and sort_method == "asc": - query = query.order_by(Task.id.asc()) - if sort_method and sort_method == "desc": - query = query.order_by(Task.id.desc()) + + if sort_param == "desc": + query = query.order_by(desc(Task.title)) + else: + query = query.order_by(Task.title) tasks = db.session.scalars(query)