From afca48e1c4da667059a0994575140921b69b48c3 Mon Sep 17 00:00:00 2001 From: Marina Adams Date: Fri, 9 May 2025 00:43:07 -0700 Subject: [PATCH 01/13] Added task routes, route helper functions; validated models, tested with Postman; passed Wave 1 tests --- app/__init__.py | 4 +- app/models/task.py | 21 +++++++ app/routes/route_utilities.py | 44 +++++++++++++ app/routes/task_routes.py | 41 +++++++++++- migrations/README | 1 + migrations/alembic.ini | 50 +++++++++++++++ migrations/env.py | 113 ++++++++++++++++++++++++++++++++++ migrations/script.py.mako | 24 ++++++++ tests/test_wave_01.py | 32 +++++----- 9 files changed, 312 insertions(+), 18 deletions(-) create mode 100644 app/routes/route_utilities.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako diff --git a/app/__init__.py b/app/__init__.py index 3c581ceeb..00418afc2 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 bp as 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..6ff913d83 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1,5 +1,26 @@ from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime 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[datetime] = mapped_column(nullable=True) + + def to_dict(self): + return { + "id": self.id, + "title": self.title, + "description": self.description, + "is_complete": self.completed_at is not None + } + + @classmethod + def from_dict(cls, task_data): + return cls( + title=task_data["title"], + description=task_data["description"], + completed_at=task_data.get("completed_at") + ) + \ 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..e7e8e238c --- /dev/null +++ b/app/routes/route_utilities.py @@ -0,0 +1,44 @@ +from flask import abort, make_response +from ..db import db +from flask import jsonify + +def validate_model(cls, model_id): + try: + model_id = int(model_id) + except: + response = {"message": f"{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 not model: + response = {"message": f"{cls.__name__} id {model_id} not found"} + abort(make_response(response, 404)) + + return model + +def create_model(cls, model_data): + try: + new_model = cls.from_dict(model_data) + + except KeyError as error: + response = {"details": "Invalid data"} + abort(make_response(response, 400)) + + db.session.add(new_model) + db.session.commit() + + return jsonify({cls.__name__.lower(): new_model.to_dict()}), 201 + +def get_models_with_filters(cls, filters=None): + query = db.select(cls) + + if filters: + for attribute, value in filters.items(): + if hasattr(cls, attribute): + query = query.where(getattr(cls, attribute).ilike(f"%{value}%")) + + models = db.session.scalars(query.order_by(cls.id)) + models_response = [model.to_dict() for model in models] + return models_response \ No newline at end of file diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 3aae38d49..e8201fadc 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1 +1,40 @@ -from flask import Blueprint \ No newline at end of file +from flask import Blueprint, request, Response +from app.models.task import Task +from .route_utilities import validate_model, create_model, get_models_with_filters +from ..db import db +from flask import jsonify + +bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") + +@bp.post("") +def create_task(): + request_body = request.get_json() + return create_model(Task, request_body) + +@bp.get("") +def get_all_tasks(): + return get_models_with_filters(Task, request.args) + +@bp.get("/") +def get_one_task(task_id): + task = validate_model(Task, task_id) + return jsonify({"task": task.to_dict()}) + +@bp.put("/") +def update_task(task_id): + task = validate_model(Task, task_id) + request_body = request.get_json() + + task.title = request_body["title"] + task.description = request_body["description"] + db.session.commit() + + return Response(status=204, mimetype="application/json") # 204 No Content + +@bp.delete("/") +def delete_task(task_id): + task = validate_model(Task, task_id) + db.session.delete(task) + db.session.commit() + + return Response(status=204, mimetype="application/json") \ 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/tests/test_wave_01.py b/tests/test_wave_01.py index 55475db79..2d2947752 100644 --- a/tests/test_wave_01.py +++ b/tests/test_wave_01.py @@ -3,7 +3,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") @@ -14,7 +14,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") @@ -33,7 +33,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") @@ -52,7 +52,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") @@ -61,13 +61,13 @@ def test_get_task_not_found(client): # Assert assert response.status_code == 404 - raise Exception("Complete test with assertion about response body") + # 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_create_task(client): # Act response = client.post("/tasks", json={ @@ -97,7 +97,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={ @@ -117,7 +117,7 @@ def test_update_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_update_task_not_found(client): # Act response = client.put("/tasks/1", json={ @@ -128,14 +128,14 @@ def test_update_task_not_found(client): # Assert assert response.status_code == 404 - - raise Exception("Complete test with assertion about response body") + assert response_body == {"message": "Task id 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_delete_task(client, one_task): # Act response = client.delete("/tasks/1") @@ -146,7 +146,7 @@ def test_delete_task(client, one_task): query = db.select(Task).where(Task.id == 1) assert db.session.scalar(query) == 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") @@ -154,8 +154,8 @@ def test_delete_task_not_found(client): # Assert assert response.status_code == 404 - - raise Exception("Complete test with assertion about response body") + + # raise Exception("Complete test with assertion about response body") # ***************************************************************** # **Complete test with assertion about response body*************** # ***************************************************************** @@ -163,7 +163,7 @@ def test_delete_task_not_found(client): assert db.session.scalars(db.select(Task)).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={ @@ -180,7 +180,7 @@ def test_create_task_must_contain_title(client): assert db.session.scalars(db.select(Task)).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 40e63a0393f1abdc7b84b507255bf67bb18cc863 Mon Sep 17 00:00:00 2001 From: Marina Adams Date: Fri, 9 May 2025 23:20:39 -0700 Subject: [PATCH 02/13] Completed Wave 2: Added sorting by title with query params. Passed tests --- app/routes/route_utilities.py | 3 +-- app/routes/task_routes.py | 18 ++++++++++--- migrations/versions/e4ad941cbf09_.py | 39 ++++++++++++++++++++++++++++ tests/test_wave_02.py | 4 +-- 4 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 migrations/versions/e4ad941cbf09_.py diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index e7e8e238c..be8e96793 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -1,6 +1,5 @@ from flask import abort, make_response from ..db import db -from flask import jsonify def validate_model(cls, model_id): try: @@ -29,7 +28,7 @@ def create_model(cls, model_data): db.session.add(new_model) db.session.commit() - return jsonify({cls.__name__.lower(): new_model.to_dict()}), 201 + return ({cls.__name__.lower(): new_model.to_dict()}), 201 def get_models_with_filters(cls, filters=None): query = db.select(cls) diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index e8201fadc..a038d0609 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1,8 +1,7 @@ -from flask import Blueprint, request, Response +from flask import Blueprint, request, Response, make_response from app.models.task import Task from .route_utilities import validate_model, create_model, get_models_with_filters from ..db import db -from flask import jsonify bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") @@ -13,12 +12,23 @@ def create_task(): @bp.get("") def get_all_tasks(): - return get_models_with_filters(Task, request.args) + sort_param = request.args.get("sort") + + query = db.select(Task) + + if sort_param == "asc": + query = query.order_by(Task.title.asc()) + elif sort_param == "desc": + query = query.order_by(Task.title.desc()) + + tasks = db.session.scalars(query).all() + + return [task.to_dict() for task in tasks], 200 @bp.get("/") def get_one_task(task_id): task = validate_model(Task, task_id) - return jsonify({"task": task.to_dict()}) + return ({"task": task.to_dict()}) @bp.put("/") def update_task(task_id): diff --git a/migrations/versions/e4ad941cbf09_.py b/migrations/versions/e4ad941cbf09_.py new file mode 100644 index 000000000..e881558e7 --- /dev/null +++ b/migrations/versions/e4ad941cbf09_.py @@ -0,0 +1,39 @@ +"""empty message + +Revision ID: e4ad941cbf09 +Revises: +Create Date: 2025-05-09 15:45:48.985330 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e4ad941cbf09' +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_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 1e018733b3b8055de0451737f52a73ddeba2e298 Mon Sep 17 00:00:00 2001 From: Marina Adams Date: Sat, 10 May 2025 00:00:46 -0700 Subject: [PATCH 03/13] Completed Wave 3: Added mark_complete and mark_incomplete route. All tests passed --- app/routes/task_routes.py | 15 +++++++++++++++ tests/test_wave_03.py | 20 +++++++++++--------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index a038d0609..4b50fea37 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -2,6 +2,7 @@ from app.models.task import Task from .route_utilities import validate_model, create_model, get_models_with_filters from ..db import db +from datetime import datetime bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") @@ -47,4 +48,18 @@ def delete_task(task_id): db.session.delete(task) db.session.commit() + return Response(status=204, mimetype="application/json") + +@bp.patch("//mark_complete") +def mark_complete(task_id): + task = validate_model(Task, task_id) + task.completed_at = datetime.utcnow() + db.session.commit() + return Response(status=204, mimetype="application/json") + +@bp.patch("//mark_incomplete") +def mark_incomplete(task_id): + task = validate_model(Task, task_id) + task.completed_at = None + db.session.commit() return Response(status=204, mimetype="application/json") \ No newline at end of file diff --git a/tests/test_wave_03.py b/tests/test_wave_03.py index d7d441695..febd3447f 100644 --- a/tests/test_wave_03.py +++ b/tests/test_wave_03.py @@ -6,7 +6,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 """ @@ -34,7 +34,7 @@ def test_mark_complete_on_incomplete_task(client, one_task): assert db.session.scalar(query).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") @@ -46,7 +46,7 @@ def test_mark_incomplete_on_complete_task(client, completed_task): assert db.session.scalar(query).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 """ @@ -74,7 +74,7 @@ def test_mark_complete_on_completed_task(client, completed_task): query = db.select(Task).where(Task.id == 1) assert db.session.scalar(query).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") @@ -86,7 +86,7 @@ def test_mark_incomplete_on_incomplete_task(client, one_task): assert db.session.scalar(query).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") @@ -94,14 +94,15 @@ def test_mark_complete_missing_task(client): # Assert assert response.status_code == 404 - - raise Exception("Complete test with assertion about response body") + assert response_body == {"message": "Task id 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") @@ -109,8 +110,9 @@ def test_mark_incomplete_missing_task(client): # Assert assert response.status_code == 404 + assert response_body == {"message": "Task id 1 not found"} - raise Exception("Complete test with assertion about response body") + # raise Exception("Complete test with assertion about response body") # ***************************************************************** # **Complete test with assertion about response body*************** # ***************************************************************** From eb67c68a4f665a91f3ca98b400cbc157e4f38034 Mon Sep 17 00:00:00 2001 From: Marina Adams Date: Sat, 10 May 2025 20:59:09 -0700 Subject: [PATCH 04/13] Finished Wave 4: Edited task routes. Implemented Slack Bot. Refactored PATCH/mark_complete. --- app/routes/task_routes.py | 47 ++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 4b50fea37..f558a34ff 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -2,7 +2,11 @@ from app.models.task import Task from .route_utilities import validate_model, create_model, get_models_with_filters from ..db import db +from dotenv import load_dotenv +load_dotenv() from datetime import datetime +import os +import requests bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") @@ -26,6 +30,36 @@ def get_all_tasks(): return [task.to_dict() for task in tasks], 200 +@bp.patch("//mark_complete") +def mark_complete(task_id): + task = validate_model(Task, task_id) + task.completed_at = datetime.today() + db.session.commit() + + slack_url = "https://slack.com/api/chat.postMessage" + slack_token = os.environ.get("SLACKBOT_TOKEN") + + headers = { + "Authorization": f"Bearer {slack_token}", + "Content-Type": "application/json" + } + + data = { + "channel": "test-slack-api", + "text": f"Someone just completed the task {task.title}" + } + + response = requests.post(slack_url, headers=headers, json=data) + return make_response(task.to_dict(), 200) + +@bp.patch("//mark_incomplete") +def mark_incomplete(task_id): + task = validate_model(Task, task_id) + task.completed_at = None + db.session.commit() + return Response(status=204, mimetype="application/json") + + @bp.get("/") def get_one_task(task_id): task = validate_model(Task, task_id) @@ -50,16 +84,3 @@ def delete_task(task_id): return Response(status=204, mimetype="application/json") -@bp.patch("//mark_complete") -def mark_complete(task_id): - task = validate_model(Task, task_id) - task.completed_at = datetime.utcnow() - db.session.commit() - return Response(status=204, mimetype="application/json") - -@bp.patch("//mark_incomplete") -def mark_incomplete(task_id): - task = validate_model(Task, task_id) - task.completed_at = None - db.session.commit() - return Response(status=204, mimetype="application/json") \ No newline at end of file From 79b01afe88ec33bf6eb8991e190030ac9f253e8e Mon Sep 17 00:00:00 2001 From: Marina Adams Date: Sat, 10 May 2025 21:35:21 -0700 Subject: [PATCH 05/13] Created Goal Model. Added migration and upgrade. --- app/__init__.py | 2 ++ app/models/goal.py | 8 +++++ app/routes/goal_routes.py | 7 +++- app/routes/task_routes.py | 2 +- .../versions/61d7672dd37e_added_model_goal.py | 32 +++++++++++++++++++ 5 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 migrations/versions/61d7672dd37e_added_model_goal.py diff --git a/app/__init__.py b/app/__init__.py index 00418afc2..d10d535b8 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 bp as 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..9ad140fec 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -3,3 +3,11 @@ class Goal(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + title: Mapped[str] + + def to_dict(self): + return {"id": self.id, "title": self.title} + + @classmethod + def from_dict(cls, data): + return cls(title=data["title"]) diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 3aae38d49..1224f0630 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -1 +1,6 @@ -from flask import Blueprint \ No newline at end of file +from flask import Blueprint, request, make_response, Response +from app.models.goal import Goal +from .. import db +from .route_utilities import validate_model, create_model, get_models_with_filters + +bp = Blueprint("goals_bp", __name__, url_prefix="/goals") \ No newline at end of file diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index f558a34ff..9ce509341 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -50,7 +50,7 @@ def mark_complete(task_id): } response = requests.post(slack_url, headers=headers, json=data) - return make_response(task.to_dict(), 200) + return make_response(task.to_dict(), 204) @bp.patch("//mark_incomplete") def mark_incomplete(task_id): diff --git a/migrations/versions/61d7672dd37e_added_model_goal.py b/migrations/versions/61d7672dd37e_added_model_goal.py new file mode 100644 index 000000000..2787044c9 --- /dev/null +++ b/migrations/versions/61d7672dd37e_added_model_goal.py @@ -0,0 +1,32 @@ +"""Added Model Goal. + +Revision ID: 61d7672dd37e +Revises: e4ad941cbf09 +Create Date: 2025-05-10 21:32:03.541894 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '61d7672dd37e' +down_revision = 'e4ad941cbf09' +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 ### From def75188968edc930887cfcdbb2e31c01ce6deb5 Mon Sep 17 00:00:00 2001 From: Marina Adams Date: Sun, 11 May 2025 01:03:02 -0700 Subject: [PATCH 06/13] Completed Wave: added goals CRUD. Changed tests. All tests passed. --- app/routes/goal_routes.py | 43 +++++++++++++++++++++- app/routes/route_utilities.py | 4 +-- app/routes/task_routes.py | 4 +-- tests/test_wave_01.py | 2 +- tests/test_wave_03.py | 4 +-- tests/test_wave_05.py | 67 ++++++++++++++++++++++------------- 6 files changed, 92 insertions(+), 32 deletions(-) diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 1224f0630..e64e0b9a2 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -3,4 +3,45 @@ from .. import db from .route_utilities import validate_model, create_model, get_models_with_filters -bp = Blueprint("goals_bp", __name__, url_prefix="/goals") \ No newline at end of file +bp = Blueprint("goals_bp", __name__, url_prefix="/goals") + +@bp.post("") +def create_goal(): + request_body = request.get_json() + return create_model(Goal,request_body) + +@bp.get("") +def get_all_goal(): + query = db.select(Goal) + + title_param = request.args.get("title") + if title_param: + query = query.where(Goal.title.ilike(f"%{title_param}%")) + + goals = db.session.scalars(query.order_by(Goal.id)) + goals_response = [goal.to_dict() for goal in goals] + + return goals_response + +@bp.get("/") +def get_one_goal(goal_id): + goal = validate_model(Goal, goal_id) + return {"goal": goal.to_dict()} + +@bp.put("/") +def update_goal_by_id(goal_id): + goal = validate_model(Goal, goal_id) + request_body = request.get_json() + + goal.title = request_body["title"] + db.session.commit() + + return Response(status=204, mimetype="application/json") + +@bp.delete("/") +def delete_task(goal_id): + goal = validate_model(Goal, goal_id) + db.session.delete(goal) + db.session.commit() + + return Response(status=204, mimetype="application/json") \ No newline at end of file diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index be8e96793..6b2fa0528 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -5,14 +5,14 @@ def validate_model(cls, model_id): try: model_id = int(model_id) except: - response = {"message": f"{cls.__name__} id {model_id} invalid"} + response = {"details": f"{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 not model: - response = {"message": f"{cls.__name__} id {model_id} not found"} + response = {"details": f"{cls.__name__} id {model_id} not found"} abort(make_response(response, 404)) return model diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 9ce509341..0a616d9b0 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -63,7 +63,7 @@ def mark_incomplete(task_id): @bp.get("/") def get_one_task(task_id): task = validate_model(Task, task_id) - return ({"task": task.to_dict()}) + return {"task": task.to_dict()} @bp.put("/") def update_task(task_id): @@ -74,7 +74,7 @@ def update_task(task_id): task.description = request_body["description"] db.session.commit() - return Response(status=204, mimetype="application/json") # 204 No Content + return Response(status=204, mimetype="application/json") @bp.delete("/") def delete_task(task_id): diff --git a/tests/test_wave_01.py b/tests/test_wave_01.py index 2d2947752..3f059589a 100644 --- a/tests/test_wave_01.py +++ b/tests/test_wave_01.py @@ -128,7 +128,7 @@ def test_update_task_not_found(client): # Assert assert response.status_code == 404 - assert response_body == {"message": "Task id 1 not found"} + assert response_body == {"details": "Task id 1 not found"} # raise Exception("Complete test with assertion about response body") # ***************************************************************** # **Complete test with assertion about response body*************** diff --git a/tests/test_wave_03.py b/tests/test_wave_03.py index febd3447f..c04af5ceb 100644 --- a/tests/test_wave_03.py +++ b/tests/test_wave_03.py @@ -94,7 +94,7 @@ def test_mark_complete_missing_task(client): # Assert assert response.status_code == 404 - assert response_body == {"message": "Task id 1 not found"} + assert response_body == {"details": "Task id 1 not found"} # raise Exception("Complete test with assertion about response body") # ***************************************************************** @@ -110,7 +110,7 @@ def test_mark_incomplete_missing_task(client): # Assert assert response.status_code == 404 - assert response_body == {"message": "Task id 1 not found"} + assert response_body == {"details": "Task id 1 not found"} # raise Exception("Complete test with assertion about response body") # ***************************************************************** diff --git a/tests/test_wave_05.py b/tests/test_wave_05.py index 222d10cf0..d2df013ce 100644 --- a/tests/test_wave_05.py +++ b/tests/test_wave_05.py @@ -1,7 +1,8 @@ import pytest +from app.models.goal import Goal +from app.db import db - -@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 +13,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 +30,8 @@ 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") @@ -46,22 +48,22 @@ 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") + # raise Exception("Complete test") # Assert # ---- Complete Test ---- # assertion 1 goes here + assert response.status_code == 404 # assertion 2 goes here + assert response_body == {"details": "Goal id 1 not found"} # ---- 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={ @@ -80,34 +82,48 @@ 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") + # raise Exception("Complete test") # Act # ---- Complete Act Here ---- - + response = client.put("/goals/1", json={"title": "Updated Goal Title"}) # Assert # ---- Complete Assertions Here ---- # assertion 1 goes here # assertion 2 goes here # assertion 3 goes here + assert response.status_code == 204 + + get_response = client.get("/goals/1") + get_body = get_response.get_json() + assert get_body == { + "goal": { + "id": 1, + "title": "Updated Goal Title" + } + } # ---- Complete Assertions Here ---- -@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") + # raise Exception("Complete test") # Act # ---- Complete Act Here ---- - + response = client.put("/goals/1", json={ + "title": "Updated Goal Title", + }) + 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 response_body == {"details": "Goal id 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") @@ -120,29 +136,32 @@ def test_delete_goal(client, one_goal): assert response.status_code == 404 response_body = response.get_json() - assert "message" in response_body - - raise Exception("Complete test with assertion about response body") + assert response_body == {"details": "Goal id 1 not found"} + + # raise Exception("Complete test with assertion about response body") # ***************************************************************** # **Complete test with assertion about response body*************** # ***************************************************************** -@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") + # raise Exception("Complete test") # Act # ---- Complete Act Here ---- + response = client.delete("/goals/1") + 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 response_body == {"details": "Goal id 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_goal_missing_title(client): # Act response = client.post("/goals", json={}) From 667474d6abf8d9024f07ad9bd5da5386e060e03b Mon Sep 17 00:00:00 2001 From: Marina Adams Date: Sun, 11 May 2025 02:08:27 -0700 Subject: [PATCH 07/13] Completed Wave 6: added one-to-many relationship bw goals and tasks. Implemented nested routes. Updeted models. Passed all tests. --- app/models/goal.py | 5 ++- app/models/task.py | 13 +++++-- app/routes/goal_routes.py | 25 +++++++++++++ ...f3c04ce4b6_migration_added_one_to_many_.py | 36 +++++++++++++++++++ tests/test_wave_06.py | 16 ++++----- 5 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 migrations/versions/8cf3c04ce4b6_migration_added_one_to_many_.py diff --git a/app/models/goal.py b/app/models/goal.py index 9ad140fec..ee18a8e4b 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -1,10 +1,13 @@ -from sqlalchemy.orm import Mapped, mapped_column +from typing import List +from sqlalchemy.orm import Mapped, mapped_column, relationship from ..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") # Parent Goal + def to_dict(self): return {"id": self.id, "title": self.title} diff --git a/app/models/task.py b/app/models/task.py index 6ff913d83..819f1d1e0 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1,6 +1,7 @@ -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime from ..db import db +from typing import Optional class Task(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) @@ -8,14 +9,22 @@ class Task(db.Model): description: Mapped[str] completed_at: Mapped[datetime] = mapped_column(nullable=True) + goal_id: Mapped[Optional[int]] = mapped_column(db.ForeignKey("goal.id"), nullable=True) + goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") # Foreign Key: each task can have goal_id + def to_dict(self): - return { + task_dict = { "id": self.id, "title": self.title, "description": self.description, "is_complete": self.completed_at is not None } + if self.goal_id: + task_dict["goal_id"] = self.goal_id + + return task_dict + @classmethod def from_dict(cls, task_data): return cls( diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index e64e0b9a2..78f084594 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -1,5 +1,6 @@ from flask import Blueprint, request, make_response, Response from app.models.goal import Goal +from app.models.task import Task from .. import db from .route_utilities import validate_model, create_model, get_models_with_filters @@ -10,6 +11,30 @@ def create_goal(): request_body = request.get_json() return create_model(Goal,request_body) +@bp.post("//tasks") +def add_tasks_to_goal(goal_id): + goal = validate_model(Goal, goal_id) + request_body = request.get_json() + task_ids = request_body.get("task_ids", []) + + goal.tasks = [] + + for task_id in task_ids: + task = validate_model(Task, task_id) + goal.tasks.append(task) + + db.session.commit() + + return {"id": goal.id, "task_ids": task_ids}, 200 + +@bp.get("//tasks") +def get_tasks_of_goal(goal_id): + goal = validate_model(Goal, goal_id) + + tasks = [task.to_dict() for task in goal.tasks] + + return {"id": goal.id, "title": goal.title, "tasks": tasks}, 200 + @bp.get("") def get_all_goal(): query = db.select(Goal) diff --git a/migrations/versions/8cf3c04ce4b6_migration_added_one_to_many_.py b/migrations/versions/8cf3c04ce4b6_migration_added_one_to_many_.py new file mode 100644 index 000000000..1174ae517 --- /dev/null +++ b/migrations/versions/8cf3c04ce4b6_migration_added_one_to_many_.py @@ -0,0 +1,36 @@ +"""Migration: added one-to-many relationship between goals and tasks. +q + + +Revision ID: 8cf3c04ce4b6 +Revises: 61d7672dd37e +Create Date: 2025-05-11 01:44:57.765674 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8cf3c04ce4b6' +down_revision = '61d7672dd37e' +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 0317f835a..74c5f4a17 100644 --- a/tests/test_wave_06.py +++ b/tests/test_wave_06.py @@ -3,7 +3,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={ @@ -25,7 +25,7 @@ def test_post_task_ids_to_goal(client, one_goal, three_tasks): assert len(db.session.scalar(query).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={ @@ -45,7 +45,7 @@ def test_post_task_ids_to_goal_already_with_goals(client, one_task_belongs_to_on assert len(db.session.scalar(query).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") @@ -53,14 +53,14 @@ def test_get_tasks_for_specific_goal_no_goal(client): # Assert assert response.status_code == 404 - - raise Exception("Complete test with assertion about response body") + assert response_body == {'details': 'Goal id 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") @@ -77,7 +77,7 @@ 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") @@ -102,7 +102,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 9fe6f7b44d35c58a274f76a1803e5481fe1558bf Mon Sep 17 00:00:00 2001 From: Marina Adams Date: Sun, 11 May 2025 13:13:55 -0700 Subject: [PATCH 08/13] Added comments to all waves --- app/__init__.py | 10 +++++----- app/models/goal.py | 8 ++++---- app/models/task.py | 15 ++++++++------- app/routes/goal_routes.py | 6 +----- app/routes/route_utilities.py | 14 ++++++++------ app/routes/task_routes.py | 11 ++++++----- 6 files changed, 32 insertions(+), 32 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index d10d535b8..5921f7f4e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -5,19 +5,19 @@ from .routes.goal_routes import bp as goals_bp import os -def create_app(config=None): +def create_app(config=None): # Factory function, creates and returns Flask app app = Flask(__name__) - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('SQLALCHEMY_DATABASE_URI') + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # saves memory + app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('SQLALCHEMY_DATABASE_URI') # sets db/s, differend db/s for different goals 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) - migrate.init_app(app, db) + db.init_app(app) # Initializes db with Flask app + migrate.init_app(app, db) # Sets up Flask-Migrate (Alembic) # Register Blueprints here app.register_blueprint(tasks_bp) diff --git a/app/models/goal.py b/app/models/goal.py index ee18a8e4b..ae934459b 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -2,15 +2,15 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db import db -class Goal(db.Model): +class Goal(db.Model): # Declares model Coal, inherits from db.Model, tells SQLAlchemy to map Goal to db table id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] - tasks: Mapped[List["Task"]] = relationship(back_populates="goal") # Parent Goal + tasks: Mapped[List["Task"]] = relationship(back_populates="goal") # Task relathionships, tells Goal has many Tasks, sets up one-many-relat*p, connects goal attr in Task to make it 2-way-relat*p - def to_dict(self): + def to_dict(self): # Transforms Goal to dict to return in API responses return {"id": self.id, "title": self.title} - @classmethod + @classmethod # Creates Goal from dict data to read JSON-requests def from_dict(cls, data): return cls(title=data["title"]) diff --git a/app/models/task.py b/app/models/task.py index 819f1d1e0..4f72b1719 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -3,16 +3,16 @@ from ..db import db from typing import Optional -class Task(db.Model): +class Task(db.Model): # Declares model Task, tells SQLAlchemy to map Task to db table id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] description: Mapped[str] completed_at: Mapped[datetime] = mapped_column(nullable=True) - goal_id: Mapped[Optional[int]] = mapped_column(db.ForeignKey("goal.id"), nullable=True) - goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") # Foreign Key: each task can have goal_id + goal_id: Mapped[Optional[int]] = mapped_column(db.ForeignKey("goal.id"), nullable=True) # Adds foreign key reference to goal.id, each task can be linked to one goal + goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") # Sets up many-to-one-relat*p from task to Goal - def to_dict(self): + def to_dict(self): # Method, prepares dict of task to JSON in API responses task_dict = { "id": self.id, "title": self.title, @@ -25,11 +25,12 @@ def to_dict(self): return task_dict - @classmethod + @classmethod # Method, builds Task obj from dict dats to read JSON-requests def from_dict(cls, task_data): + return cls( - title=task_data["title"], - description=task_data["description"], + title=task_data["title"], + description=task_data["description"], completed_at=task_data.get("completed_at") ) \ No newline at end of file diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 78f084594..e7292acd2 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -16,7 +16,6 @@ def add_tasks_to_goal(goal_id): goal = validate_model(Goal, goal_id) request_body = request.get_json() task_ids = request_body.get("task_ids", []) - goal.tasks = [] for task_id in task_ids: @@ -30,7 +29,6 @@ def add_tasks_to_goal(goal_id): @bp.get("//tasks") def get_tasks_of_goal(goal_id): goal = validate_model(Goal, goal_id) - tasks = [task.to_dict() for task in goal.tasks] return {"id": goal.id, "title": goal.title, "tasks": tasks}, 200 @@ -38,11 +36,9 @@ def get_tasks_of_goal(goal_id): @bp.get("") def get_all_goal(): query = db.select(Goal) - title_param = request.args.get("title") if title_param: query = query.where(Goal.title.ilike(f"%{title_param}%")) - goals = db.session.scalars(query.order_by(Goal.id)) goals_response = [goal.to_dict() for goal in goals] @@ -51,13 +47,13 @@ def get_all_goal(): @bp.get("/") def get_one_goal(goal_id): goal = validate_model(Goal, goal_id) + return {"goal": goal.to_dict()} @bp.put("/") def update_goal_by_id(goal_id): goal = validate_model(Goal, goal_id) request_body = request.get_json() - goal.title = request_body["title"] db.session.commit() diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 6b2fa0528..ebd495b2f 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -1,7 +1,8 @@ from flask import abort, make_response from ..db import db -def validate_model(cls, model_id): +# Helper functions, exist separately, repetitive +def validate_model(cls, model_id): # Defines a reusable function to find a model instance by id try: model_id = int(model_id) except: @@ -17,7 +18,7 @@ def validate_model(cls, model_id): return model -def create_model(cls, model_data): +def create_model(cls, model_data): # Defines a function to create a new model instance (Task or Goal) using a dictionary try: new_model = cls.from_dict(model_data) @@ -25,12 +26,12 @@ def create_model(cls, model_data): response = {"details": "Invalid data"} abort(make_response(response, 400)) - db.session.add(new_model) + db.session.add(new_model) # Adds the new model to the database and saves it db.session.commit() return ({cls.__name__.lower(): new_model.to_dict()}), 201 -def get_models_with_filters(cls, filters=None): +def get_models_with_filters(cls, filters=None): # Fetches multiple records of a model (tasks/goals), optionally filtering by query string values query = db.select(cls) if filters: @@ -38,6 +39,7 @@ def get_models_with_filters(cls, filters=None): if hasattr(cls, attribute): query = query.where(getattr(cls, attribute).ilike(f"%{value}%")) - models = db.session.scalars(query.order_by(cls.id)) - models_response = [model.to_dict() for model in models] + models = db.session.scalars(query.order_by(cls.id)) # Executes the query and sorts results by id + models_response = [model.to_dict() for model in models] # Converts all model instances to dictionaries for JSON output + return models_response \ No newline at end of file diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 0a616d9b0..0841325ed 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -8,17 +8,17 @@ import os import requests -bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") +bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") # Declares a Blueprint for all routes starting with /tasks @bp.post("") def create_task(): request_body = request.get_json() + return create_model(Task, request_body) @bp.get("") def get_all_tasks(): sort_param = request.args.get("sort") - query = db.select(Task) if sort_param == "asc": @@ -49,7 +49,8 @@ def mark_complete(task_id): "text": f"Someone just completed the task {task.title}" } - response = requests.post(slack_url, headers=headers, json=data) + response = requests.post(slack_url, headers=headers, json=data) + return make_response(task.to_dict(), 204) @bp.patch("//mark_incomplete") @@ -57,19 +58,19 @@ def mark_incomplete(task_id): task = validate_model(Task, task_id) task.completed_at = None db.session.commit() + return Response(status=204, mimetype="application/json") - @bp.get("/") def get_one_task(task_id): task = validate_model(Task, task_id) + return {"task": task.to_dict()} @bp.put("/") def update_task(task_id): task = validate_model(Task, task_id) request_body = request.get_json() - task.title = request_body["title"] task.description = request_body["description"] db.session.commit() From fcc8bb936232f46a8738f31375e9d5eef90370a2 Mon Sep 17 00:00:00 2001 From: Marina Adams Date: Mon, 26 May 2025 23:28:41 -0700 Subject: [PATCH 09/13] Resolved: removed unnecessary comments per review --- app/__init__.py | 10 +++++----- app/models/goal.py | 8 ++++---- app/models/task.py | 10 +++++----- app/routes/route_utilities.py | 13 ++++++------- app/routes/task_routes.py | 2 +- 5 files changed, 21 insertions(+), 22 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 5921f7f4e..b3089bc17 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -5,19 +5,19 @@ from .routes.goal_routes import bp as goals_bp import os -def create_app(config=None): # Factory function, creates and returns Flask app +def create_app(config=None): app = Flask(__name__) - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # saves memory - app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('SQLALCHEMY_DATABASE_URI') # sets db/s, differend db/s for different goals + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + 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) # Initializes db with Flask app - migrate.init_app(app, db) # Sets up Flask-Migrate (Alembic) + db.init_app(app) + migrate.init_app(app, db) # Register Blueprints here app.register_blueprint(tasks_bp) diff --git a/app/models/goal.py b/app/models/goal.py index ae934459b..b0728299e 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -2,15 +2,15 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db import db -class Goal(db.Model): # Declares model Coal, inherits from db.Model, tells SQLAlchemy to map Goal to db table +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") # Task relathionships, tells Goal has many Tasks, sets up one-many-relat*p, connects goal attr in Task to make it 2-way-relat*p + tasks: Mapped[List["Task"]] = relationship(back_populates="goal") - def to_dict(self): # Transforms Goal to dict to return in API responses + def to_dict(self): return {"id": self.id, "title": self.title} - @classmethod # Creates Goal from dict data to read JSON-requests + @classmethod def from_dict(cls, data): return cls(title=data["title"]) diff --git a/app/models/task.py b/app/models/task.py index 4f72b1719..dc5b3593e 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -3,16 +3,16 @@ from ..db import db from typing import Optional -class Task(db.Model): # Declares model Task, tells SQLAlchemy to map Task to db table +class Task(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] description: Mapped[str] completed_at: Mapped[datetime] = mapped_column(nullable=True) - goal_id: Mapped[Optional[int]] = mapped_column(db.ForeignKey("goal.id"), nullable=True) # Adds foreign key reference to goal.id, each task can be linked to one goal - goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") # Sets up many-to-one-relat*p from task to Goal + goal_id: Mapped[Optional[int]] = mapped_column(db.ForeignKey("goal.id"), nullable=True) + goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") - def to_dict(self): # Method, prepares dict of task to JSON in API responses + def to_dict(self): task_dict = { "id": self.id, "title": self.title, @@ -25,7 +25,7 @@ def to_dict(self): # Method, prepares dict of task to JSON in API responses return task_dict - @classmethod # Method, builds Task obj from dict dats to read JSON-requests + @classmethod def from_dict(cls, task_data): return cls( diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index ebd495b2f..8e52ba16b 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -1,8 +1,7 @@ from flask import abort, make_response from ..db import db -# Helper functions, exist separately, repetitive -def validate_model(cls, model_id): # Defines a reusable function to find a model instance by id +def validate_model(cls, model_id): try: model_id = int(model_id) except: @@ -18,7 +17,7 @@ def validate_model(cls, model_id): # Defines a reusable function to find a model return model -def create_model(cls, model_data): # Defines a function to create a new model instance (Task or Goal) using a dictionary +def create_model(cls, model_data): try: new_model = cls.from_dict(model_data) @@ -26,12 +25,12 @@ def create_model(cls, model_data): # Defines a function to create a new model in response = {"details": "Invalid data"} abort(make_response(response, 400)) - db.session.add(new_model) # Adds the new model to the database and saves it + db.session.add(new_model) db.session.commit() return ({cls.__name__.lower(): new_model.to_dict()}), 201 -def get_models_with_filters(cls, filters=None): # Fetches multiple records of a model (tasks/goals), optionally filtering by query string values +def get_models_with_filters(cls, filters=None): query = db.select(cls) if filters: @@ -39,7 +38,7 @@ def get_models_with_filters(cls, filters=None): # Fetches multiple records of a if hasattr(cls, attribute): query = query.where(getattr(cls, attribute).ilike(f"%{value}%")) - models = db.session.scalars(query.order_by(cls.id)) # Executes the query and sorts results by id - models_response = [model.to_dict() for model in models] # Converts all model instances to dictionaries for JSON output + models = db.session.scalars(query.order_by(cls.id)) + models_response = [model.to_dict() for model in models] return models_response \ No newline at end of file diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 0841325ed..116784282 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -8,7 +8,7 @@ import os import requests -bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") # Declares a Blueprint for all routes starting with /tasks +bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") @bp.post("") def create_task(): From bf75dce4305f6ccf66a69e32b2fb4a11fbc9c9c6 Mon Sep 17 00:00:00 2001 From: Marina Adams Date: Tue, 27 May 2025 01:10:24 -0700 Subject: [PATCH 10/13] Fixed: white spacing, blank lines, imports according PEP8 --- app/__init__.py | 5 ++++- app/db.py | 2 ++ app/models/base.py | 3 ++- app/models/goal.py | 4 +++- app/models/task.py | 15 ++++++++------- app/routes/goal_routes.py | 8 +++++--- app/routes/route_utilities.py | 3 ++- app/routes/task_routes.py | 24 ++++++++++++++++++------ 8 files changed, 44 insertions(+), 20 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index b3089bc17..f4a757c17 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,9 +1,12 @@ +import os + from flask import Flask + from .db import db, migrate from .models import task, goal from .routes.task_routes import bp as tasks_bp from .routes.goal_routes import bp as goals_bp -import os + def create_app(config=None): app = Flask(__name__) diff --git a/app/db.py b/app/db.py index 3ada8d10c..deda8ee2c 100644 --- a/app/db.py +++ b/app/db.py @@ -1,6 +1,8 @@ from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate + from .models.base import Base + db = SQLAlchemy(model_class=Base) migrate = Migrate() \ No newline at end of file diff --git a/app/models/base.py b/app/models/base.py index 227841686..fa2b68a5d 100644 --- a/app/models/base.py +++ b/app/models/base.py @@ -1,4 +1,5 @@ from sqlalchemy.orm import DeclarativeBase + class Base(DeclarativeBase): - pass \ No newline at end of file + pass diff --git a/app/models/goal.py b/app/models/goal.py index b0728299e..153e731d1 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -1,11 +1,13 @@ from typing import List + from sqlalchemy.orm import Mapped, mapped_column, relationship + from ..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): diff --git a/app/models/task.py b/app/models/task.py index dc5b3593e..a3e40f130 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1,15 +1,17 @@ -from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime -from ..db import db from typing import Optional -class Task(db.Model): +from sqlalchemy.orm import Mapped, mapped_column, relationship + +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[datetime] = mapped_column(nullable=True) - - goal_id: Mapped[Optional[int]] = mapped_column(db.ForeignKey("goal.id"), nullable=True) + goal_id: Mapped[Optional[int]] = mapped_column(db.ForeignKey("goal.id"), nullable=True) goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") def to_dict(self): @@ -32,5 +34,4 @@ def from_dict(cls, task_data): title=task_data["title"], description=task_data["description"], completed_at=task_data.get("completed_at") - ) - \ No newline at end of file + ) diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index e7292acd2..edc5e055e 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -1,4 +1,5 @@ from flask import Blueprint, request, make_response, Response + from app.models.goal import Goal from app.models.task import Task from .. import db @@ -6,10 +7,11 @@ bp = Blueprint("goals_bp", __name__, url_prefix="/goals") + @bp.post("") def create_goal(): request_body = request.get_json() - return create_model(Goal,request_body) + return create_model(Goal, request_body) @bp.post("//tasks") def add_tasks_to_goal(goal_id): @@ -48,7 +50,7 @@ def get_all_goal(): def get_one_goal(goal_id): goal = validate_model(Goal, goal_id) - return {"goal": goal.to_dict()} + return {"goal": goal.to_dict()} @bp.put("/") def update_goal_by_id(goal_id): @@ -60,7 +62,7 @@ def update_goal_by_id(goal_id): return Response(status=204, mimetype="application/json") @bp.delete("/") -def delete_task(goal_id): +def delete_goal(goal_id): goal = validate_model(Goal, goal_id) db.session.delete(goal) db.session.commit() diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 8e52ba16b..643153c24 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -1,4 +1,5 @@ from flask import abort, make_response + from ..db import db def validate_model(cls, model_id): @@ -6,7 +7,7 @@ def validate_model(cls, model_id): model_id = int(model_id) except: response = {"details": f"{cls.__name__} id {model_id} invalid"} - abort(make_response(response , 400)) + abort(make_response(response, 400)) query = db.select(cls).where(cls.id == model_id) model = db.session.scalar(query) diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 116784282..2bfeb4659 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1,21 +1,27 @@ +from datetime import datetime +import os + from flask import Blueprint, request, Response, make_response +import requests +from dotenv import load_dotenv + from app.models.task import Task from .route_utilities import validate_model, create_model, get_models_with_filters from ..db import db -from dotenv import load_dotenv + + load_dotenv() -from datetime import datetime -import os -import requests bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") + @bp.post("") def create_task(): request_body = request.get_json() return create_model(Task, request_body) + @bp.get("") def get_all_tasks(): sort_param = request.args.get("sort") @@ -30,6 +36,7 @@ def get_all_tasks(): return [task.to_dict() for task in tasks], 200 + @bp.patch("//mark_complete") def mark_complete(task_id): task = validate_model(Task, task_id) @@ -49,10 +56,13 @@ def mark_complete(task_id): "text": f"Someone just completed the task {task.title}" } - response = requests.post(slack_url, headers=headers, json=data) + response = requests.post(slack_url, headers=headers, json=data) # ?? check response + # if response.status_code != 200: + # abort(make_response({"details": "Slack error"}, 500)) return make_response(task.to_dict(), 204) + @bp.patch("//mark_incomplete") def mark_incomplete(task_id): task = validate_model(Task, task_id) @@ -61,12 +71,14 @@ def mark_incomplete(task_id): return Response(status=204, mimetype="application/json") + @bp.get("/") def get_one_task(task_id): task = validate_model(Task, task_id) return {"task": task.to_dict()} + @bp.put("/") def update_task(task_id): task = validate_model(Task, task_id) @@ -77,6 +89,7 @@ def update_task(task_id): return Response(status=204, mimetype="application/json") + @bp.delete("/") def delete_task(task_id): task = validate_model(Task, task_id) @@ -84,4 +97,3 @@ def delete_task(task_id): db.session.commit() return Response(status=204, mimetype="application/json") - From b9f21bc420df0a9dbfd31760f6cbddd9d18cb5e9 Mon Sep 17 00:00:00 2001 From: Marina Adams Date: Tue, 27 May 2025 03:16:13 -0700 Subject: [PATCH 11/13] Fixed: refactored models, routes, added helper functions; completed tests --- app/models/task.py | 6 +++--- app/routes/goal_routes.py | 24 ++++++++++++++++-------- app/routes/route_utilities.py | 20 +++++++++++++++++++- app/routes/task_routes.py | 27 +++++++-------------------- tests/test_wave_01.py | 7 +++++-- tests/test_wave_05.py | 13 +++++++++---- 6 files changed, 59 insertions(+), 38 deletions(-) diff --git a/app/models/task.py b/app/models/task.py index a3e40f130..d6bd5d93c 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -10,9 +10,9 @@ class Task(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] description: Mapped[str] - completed_at: Mapped[datetime] = mapped_column(nullable=True) - goal_id: Mapped[Optional[int]] = mapped_column(db.ForeignKey("goal.id"), nullable=True) - goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") + completed_at: Mapped[Optional[datetime]] + goal_id: Mapped[Optional[int]] = mapped_column(db.ForeignKey("goal.id")) + goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") def to_dict(self): task_dict = { diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index edc5e055e..b2b92209c 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -13,6 +13,7 @@ def create_goal(): request_body = request.get_json() return create_model(Goal, request_body) + @bp.post("//tasks") def add_tasks_to_goal(goal_id): goal = validate_model(Goal, goal_id) @@ -26,25 +27,26 @@ def add_tasks_to_goal(goal_id): db.session.commit() - return {"id": goal.id, "task_ids": task_ids}, 200 + return {"id": goal.id, "task_ids": task_ids} + @bp.get("//tasks") def get_tasks_of_goal(goal_id): goal = validate_model(Goal, goal_id) tasks = [task.to_dict() for task in goal.tasks] - return {"id": goal.id, "title": goal.title, "tasks": tasks}, 200 + return {"id": goal.id, "title": goal.title, "tasks": tasks} + @bp.get("") -def get_all_goal(): - query = db.select(Goal) +def get_all_goals(): + filters = {} title_param = request.args.get("title") if title_param: - query = query.where(Goal.title.ilike(f"%{title_param}%")) - goals = db.session.scalars(query.order_by(Goal.id)) - goals_response = [goal.to_dict() for goal in goals] + filters["title"] = title_param + + return get_models_with_filters(Goal, filters) - return goals_response @bp.get("/") def get_one_goal(goal_id): @@ -52,15 +54,21 @@ def get_one_goal(goal_id): return {"goal": goal.to_dict()} + @bp.put("/") def update_goal_by_id(goal_id): goal = validate_model(Goal, goal_id) request_body = request.get_json() + + if "title" not in request_body: + return {"details": "Invalid data. 'title' is required."}, 400 + goal.title = request_body["title"] db.session.commit() return Response(status=204, mimetype="application/json") + @bp.delete("/") def delete_goal(goal_id): goal = validate_model(Goal, goal_id) diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 643153c24..91fe1e06e 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -1,3 +1,4 @@ +import os, requests from flask import abort, make_response from ..db import db @@ -42,4 +43,21 @@ def get_models_with_filters(cls, filters=None): models = db.session.scalars(query.order_by(cls.id)) models_response = [model.to_dict() for model in models] - return models_response \ No newline at end of file + return models_response + +def call_slack_api(task): + slack_url = "https://slack.com/api/chat.postMessage" + slack_token = os.environ.get("SLACKBOT_TOKEN") + + headers = { + "Authorization": f"Bearer {slack_token}", + "Content-Type": "application/json" + } + + data = { + "channel": "test-slack-api", + "text": f"Someone just completed the task {task.title}" + } + + responce = requests.post(slack_url, headers=headers, json=data) + return responce \ No newline at end of file diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 2bfeb4659..33ee4f738 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1,15 +1,12 @@ from datetime import datetime -import os from flask import Blueprint, request, Response, make_response -import requests from dotenv import load_dotenv from app.models.task import Task -from .route_utilities import validate_model, create_model, get_models_with_filters +from .route_utilities import validate_model, create_model, get_models_with_filters, call_slack_api from ..db import db - load_dotenv() bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") @@ -31,10 +28,11 @@ def get_all_tasks(): query = query.order_by(Task.title.asc()) elif sort_param == "desc": query = query.order_by(Task.title.desc()) + else: + query = query.order_by(Task.id) tasks = db.session.scalars(query).all() - - return [task.to_dict() for task in tasks], 200 + return [task.to_dict() for task in tasks] @bp.patch("//mark_complete") @@ -43,20 +41,9 @@ def mark_complete(task_id): task.completed_at = datetime.today() db.session.commit() - slack_url = "https://slack.com/api/chat.postMessage" - slack_token = os.environ.get("SLACKBOT_TOKEN") - - headers = { - "Authorization": f"Bearer {slack_token}", - "Content-Type": "application/json" - } - - data = { - "channel": "test-slack-api", - "text": f"Someone just completed the task {task.title}" - } - - response = requests.post(slack_url, headers=headers, json=data) # ?? check response + call_slack_api(task) + + # ?? check response # if response.status_code != 200: # abort(make_response({"details": "Slack error"}, 500)) diff --git a/tests/test_wave_01.py b/tests/test_wave_01.py index 3f059589a..5a10fad5a 100644 --- a/tests/test_wave_01.py +++ b/tests/test_wave_01.py @@ -60,6 +60,7 @@ def test_get_task_not_found(client): # Assert assert response.status_code == 404 + assert response_body == {"details": "Task id 1 not found"} # raise Exception("Complete test with assertion about response body") # ***************************************************************** @@ -116,7 +117,6 @@ def test_update_task(client, one_task): assert task.completed_at == None - # @pytest.mark.skip(reason="No way to test this feature yet") def test_update_task_not_found(client): # Act @@ -129,6 +129,7 @@ def test_update_task_not_found(client): # Assert assert response.status_code == 404 assert response_body == {"details": "Task id 1 not found"} + # raise Exception("Complete test with assertion about response body") # ***************************************************************** # **Complete test with assertion about response body*************** @@ -146,6 +147,7 @@ def test_delete_task(client, one_task): query = db.select(Task).where(Task.id == 1) assert db.session.scalar(query) == None + # @pytest.mark.skip(reason="No way to test this feature yet") def test_delete_task_not_found(client): # Act @@ -154,7 +156,8 @@ def test_delete_task_not_found(client): # Assert assert response.status_code == 404 - + assert response_body == {"details": "Task id 1 not found"} + # raise Exception("Complete test with assertion about response body") # ***************************************************************** # **Complete test with assertion about response body*************** diff --git a/tests/test_wave_05.py b/tests/test_wave_05.py index d2df013ce..0d776d2d0 100644 --- a/tests/test_wave_05.py +++ b/tests/test_wave_05.py @@ -89,11 +89,15 @@ def test_update_goal(client, one_goal): # ---- Complete Act Here ---- response = client.put("/goals/1", json={"title": "Updated Goal Title"}) # Assert + query = db.select(Goal).where(Goal.id == 1) + goal = db.session.scalar(query) # ---- Complete Assertions Here ---- # assertion 1 goes here - # assertion 2 goes here - # assertion 3 goes here assert response.status_code == 204 + # assertion 2 goes here + assert goal.title == "Updated Goal Title" + # assertion 3 goes here + assert goal.id == 1 get_response = client.get("/goals/1") get_body = get_response.get_json() @@ -118,10 +122,11 @@ def test_update_goal_not_found(client): # Assert # ---- Complete Assertions Here ---- # assertion 1 goes here - # assertion 2 goes here - # ---- Complete Assertions Here ---- assert response.status_code == 404 + # assertion 2 goes here assert response_body == {"details": "Goal id 1 not found"} + # ---- Complete Assertions Here ---- + # @pytest.mark.skip(reason="No way to test this feature yet") def test_delete_goal(client, one_goal): From eef3ccc92a8b8bbf31a28a4dd5ace4d045cf2d09 Mon Sep 17 00:00:00 2001 From: Marina Adams Date: Wed, 28 May 2025 01:56:34 -0700 Subject: [PATCH 12/13] Fixed: final refactoring --- app/__init__.py | 12 +-- app/db.py | 2 +- app/models/goal.py | 10 +-- app/models/task.py | 23 +++-- app/routes/goal_routes.py | 20 ++--- app/routes/route_utilities.py | 46 ++++++---- app/routes/task_routes.py | 41 +++------ cli/main.py | 89 +++++++++++-------- cli/task_list.py | 42 +++++---- migrations/env.py | 31 +++---- .../versions/61d7672dd37e_added_model_goal.py | 13 +-- ...f3c04ce4b6_migration_added_one_to_many_.py | 17 ++-- migrations/versions/e4ad941cbf09_.py | 29 +++--- tests/conftest.py | 41 +++++---- tests/test_wave_01.py | 61 ++++++------- tests/test_wave_02.py | 18 ++-- tests/test_wave_03.py | 7 +- tests/test_wave_05.py | 51 ++++------- tests/test_wave_06.py | 32 +++---- 19 files changed, 287 insertions(+), 298 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index f4a757c17..16731e550 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -8,22 +8,22 @@ from .routes.goal_routes import bp as goals_bp -def create_app(config=None): +def create_app(config=None): app = Flask(__name__) - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('SQLALCHEMY_DATABASE_URI') + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + 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) - migrate.init_app(app, db) + db.init_app(app) + migrate.init_app(app, db) # Register Blueprints here app.register_blueprint(tasks_bp) app.register_blueprint(goals_bp) - + return app diff --git a/app/db.py b/app/db.py index deda8ee2c..bf98adaa2 100644 --- a/app/db.py +++ b/app/db.py @@ -5,4 +5,4 @@ db = SQLAlchemy(model_class=Base) -migrate = Migrate() \ No newline at end of file +migrate = Migrate() diff --git a/app/models/goal.py b/app/models/goal.py index 153e731d1..4461d5692 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -5,14 +5,14 @@ from ..db import db -class Goal(db.Model): +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): + tasks: Mapped[List["Task"]] = relationship(back_populates="goal") + + def to_dict(self): return {"id": self.id, "title": self.title} - @classmethod + @classmethod def from_dict(cls, data): return cls(title=data["title"]) diff --git a/app/models/task.py b/app/models/task.py index d6bd5d93c..a3e09f1eb 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -14,24 +14,23 @@ class Task(db.Model): goal_id: Mapped[Optional[int]] = mapped_column(db.ForeignKey("goal.id")) goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") - def to_dict(self): + def to_dict(self): task_dict = { "id": self.id, "title": self.title, "description": self.description, - "is_complete": self.completed_at is not None - } - + "is_complete": self.completed_at is not None, + } + if self.goal_id: task_dict["goal_id"] = self.goal_id - + return task_dict - - @classmethod + + @classmethod def from_dict(cls, task_data): - return cls( - title=task_data["title"], - description=task_data["description"], - completed_at=task_data.get("completed_at") - ) + title=task_data["title"], + description=task_data["description"], + completed_at=task_data.get("completed_at"), + ) diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index b2b92209c..ca1314b87 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -1,4 +1,4 @@ -from flask import Blueprint, request, make_response, Response +from flask import Blueprint, request, Response from app.models.goal import Goal from app.models.task import Task @@ -26,7 +26,6 @@ def add_tasks_to_goal(goal_id): goal.tasks.append(task) db.session.commit() - return {"id": goal.id, "task_ids": task_ids} @@ -34,38 +33,30 @@ def add_tasks_to_goal(goal_id): def get_tasks_of_goal(goal_id): goal = validate_model(Goal, goal_id) tasks = [task.to_dict() for task in goal.tasks] - return {"id": goal.id, "title": goal.title, "tasks": tasks} @bp.get("") def get_all_goals(): - filters = {} - title_param = request.args.get("title") - if title_param: - filters["title"] = title_param - - return get_models_with_filters(Goal, filters) + return get_models_with_filters(Goal, request.args) @bp.get("/") def get_one_goal(goal_id): goal = validate_model(Goal, goal_id) - return {"goal": goal.to_dict()} @bp.put("/") -def update_goal_by_id(goal_id): +def update_goal(goal_id): goal = validate_model(Goal, goal_id) request_body = request.get_json() - + if "title" not in request_body: return {"details": "Invalid data. 'title' is required."}, 400 goal.title = request_body["title"] db.session.commit() - return Response(status=204, mimetype="application/json") @@ -74,5 +65,4 @@ def delete_goal(goal_id): goal = validate_model(Goal, goal_id) db.session.delete(goal) db.session.commit() - - return Response(status=204, mimetype="application/json") \ No newline at end of file + return Response(status=204, mimetype="application/json") diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 91fe1e06e..98c663924 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -3,7 +3,8 @@ from ..db import db -def validate_model(cls, model_id): + +def validate_model(cls, model_id): try: model_id = int(model_id) except: @@ -12,38 +13,49 @@ def validate_model(cls, model_id): query = db.select(cls).where(cls.id == model_id) model = db.session.scalar(query) - + if not model: response = {"details": f"{cls.__name__} id {model_id} not found"} abort(make_response(response, 404)) - + return model -def create_model(cls, model_data): + +def create_model(cls, model_data): try: new_model = cls.from_dict(model_data) - + except KeyError as error: response = {"details": "Invalid data"} abort(make_response(response, 400)) - - db.session.add(new_model) - db.session.commit() + db.session.add(new_model) + db.session.commit() return ({cls.__name__.lower(): new_model.to_dict()}), 201 -def get_models_with_filters(cls, filters=None): + +def get_models_with_filters(cls, filters=None): query = db.select(cls) - + sort_param = None + if filters: - for attribute, value in filters.items(): + filters_dict = dict(filters) # Convert ImmutableMultiDict to dict + sort_param = filters_dict.pop("sort", None) + + for attribute, value in filters_dict.items(): if hasattr(cls, attribute): query = query.where(getattr(cls, attribute).ilike(f"%{value}%")) - models = db.session.scalars(query.order_by(cls.id)) - models_response = [model.to_dict() for model in models] + if sort_param == "asc": + query = query.order_by(cls.title.asc()) + elif sort_param == "desc": + query = query.order_by(cls.title.desc()) + else: + query = query.order_by(cls.id) + + models = db.session.scalars(query) + return [model.to_dict() for model in models] - return models_response def call_slack_api(task): slack_url = "https://slack.com/api/chat.postMessage" @@ -51,13 +63,13 @@ def call_slack_api(task): headers = { "Authorization": f"Bearer {slack_token}", - "Content-Type": "application/json" + "Content-Type": "application/json", } data = { "channel": "test-slack-api", - "text": f"Someone just completed the task {task.title}" + "text": f"Someone just completed the task {task.title}", } responce = requests.post(slack_url, headers=headers, json=data) - return responce \ No newline at end of file + return responce diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 33ee4f738..3f4bafd28 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1,38 +1,30 @@ from datetime import datetime from flask import Blueprint, request, Response, make_response -from dotenv import load_dotenv from app.models.task import Task -from .route_utilities import validate_model, create_model, get_models_with_filters, call_slack_api +from .route_utilities import ( + validate_model, + create_model, + get_models_with_filters, + call_slack_api, +) from ..db import db -load_dotenv() -bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") +bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") @bp.post("") def create_task(): request_body = request.get_json() - + return create_model(Task, request_body) @bp.get("") def get_all_tasks(): - sort_param = request.args.get("sort") - query = db.select(Task) - - if sort_param == "asc": - query = query.order_by(Task.title.asc()) - elif sort_param == "desc": - query = query.order_by(Task.title.desc()) - else: - query = query.order_by(Task.id) - - tasks = db.session.scalars(query).all() - return [task.to_dict() for task in tasks] + return get_models_with_filters(Task, request.args) @bp.patch("//mark_complete") @@ -42,11 +34,6 @@ def mark_complete(task_id): db.session.commit() call_slack_api(task) - - # ?? check response - # if response.status_code != 200: - # abort(make_response({"details": "Slack error"}, 500)) - return make_response(task.to_dict(), 204) @@ -55,14 +42,12 @@ def mark_incomplete(task_id): task = validate_model(Task, task_id) task.completed_at = None db.session.commit() - return Response(status=204, mimetype="application/json") @bp.get("/") def get_one_task(task_id): task = validate_model(Task, task_id) - return {"task": task.to_dict()} @@ -70,11 +55,14 @@ def get_one_task(task_id): def update_task(task_id): task = validate_model(Task, task_id) request_body = request.get_json() + + if "title" not in request_body or "description" not in request_body: + return make_response({"details": "Invalid data"}, 400) + task.title = request_body["title"] task.description = request_body["description"] db.session.commit() - - return Response(status=204, mimetype="application/json") + return Response(status=204, mimetype="application/json") @bp.delete("/") @@ -82,5 +70,4 @@ def delete_task(task_id): task = validate_model(Task, task_id) db.session.delete(task) db.session.commit() - return Response(status=204, mimetype="application/json") diff --git a/cli/main.py b/cli/main.py index 04d8e0f5d..bfc76b785 100644 --- a/cli/main.py +++ b/cli/main.py @@ -1,17 +1,18 @@ import task_list OPTIONS = { - "1": "List all tasks", - "2": "Create a task", - "3": "View one task", - "4": "Update task", - "5": "Delete task", - "6": "Mark complete", - "7": "Mark incomplete", - "8": "Delete all tasks", - "9": "List all options", - "10": "Quit" - } + "1": "List all tasks", + "2": "Create a task", + "3": "View one task", + "4": "Update task", + "5": "Delete task", + "6": "Mark complete", + "7": "Mark incomplete", + "8": "Delete all tasks", + "9": "List all options", + "10": "Quit", +} + def list_options(): @@ -25,18 +26,21 @@ def make_choice(): while choice not in valid_choices: print("\n What would you like to do? ") - choice = input("Make your selection using the option number. Select 9 to list all options: ") + choice = input( + "Make your selection using the option number. Select 9 to list all options: " + ) return choice -def get_task_from_user(msg = "Input the id of the task you would like to work with: "): + +def get_task_from_user(msg="Input the id of the task you would like to work with: "): task = None tasks = task_list.list_tasks() if not tasks: task_list.print_stars("This option is not possible because there are no tasks.") return task count = 0 - help_count = 3 #number of tries before offering assistance + help_count = 3 # number of tries before offering assistance while not task: id = input(msg) task = task_list.get_task(id) @@ -44,11 +48,14 @@ def get_task_from_user(msg = "Input the id of the task you would like to work wi print_surround_stars("I cannot find that task. Please try again.") count += 1 if count >= help_count: - print("You seem to be having trouble selecting a task. Please choose from the following list of tasks.") + print( + "You seem to be having trouble selecting a task. Please choose from the following list of tasks." + ) print_all_tasks() - + return task + def print_task(task): print_single_row_of_stars() print("title: ", task["title"]) @@ -57,6 +64,7 @@ def print_task(task): print("id: ", task["id"]) print_single_row_of_stars() + def print_all_tasks(): tasks = task_list.list_tasks() print("\nTasks:") @@ -67,36 +75,42 @@ def print_all_tasks(): print_task(task) print_single_row_of_stars() + def print_surround_stars(sentence): print_single_row_of_stars() print(sentence) print_single_row_of_stars() + def print_single_row_of_stars(): print("\n**************************\n") + def create_task(): print("Great! Let's create a new task.") - title=input("What is the title of your task? ") - description=input("What is the description of your task? ") + title = input("What is the title of your task? ") + description = input("What is the description of your task? ") response = task_list.create_task(title, description) print_task(response) + def view_task(): task = get_task_from_user("Input the id of the task you would like to select ") - if task: + if task: print("\nSelected Task:") print_task(task) + def edit_task(): task = get_task_from_user() if task: - title=input("What is the new title of your task? ") - description=input("What is the new description of your task? ") + title = input("What is the new title of your task? ") + description = input("What is the new description of your task? ") response = task_list.update_task(task["id"], title, description) print("\nUpdated Task:") print_task(response) + def delete_task_ui(): task = get_task_from_user("Input the id of the task you would like to delete: ") if task: @@ -104,11 +118,14 @@ def delete_task_ui(): print("\nTask has been deleted.") print_all_tasks() + def change_task_complete_status(status): status_text = "complete" if not status: status_text = "incomplete" - task = get_task_from_user(f"Input the id of the task you would like to mark {status_text}: ") + task = get_task_from_user( + f"Input the id of the task you would like to mark {status_text}: " + ) if task: if status: response = task_list.mark_complete(task["id"]) @@ -117,43 +134,45 @@ def change_task_complete_status(status): print(f"\nTask marked {status_text}:") print_task(response) + def delete_all_tasks(): for task in task_list.list_tasks(): task_list.delete_task(task["id"]) print_surround_stars("Deleted all tasks.") + def run_cli(): - + play = True while play: # get input and validate choice = make_choice() - if choice=='1': + if choice == "1": print_all_tasks() - elif choice=='2': + elif choice == "2": create_task() - elif choice=='3': + elif choice == "3": view_task() - elif choice=='4': + elif choice == "4": edit_task() - elif choice=='5': + elif choice == "5": delete_task_ui() - elif choice=='6': + elif choice == "6": change_task_complete_status(True) - elif choice=='7': + elif choice == "7": change_task_complete_status(False) - elif choice=='8': + elif choice == "8": delete_all_tasks() - elif choice=='9': + elif choice == "9": list_options() - elif choice=='10': - play=False + elif choice == "10": + play = False print("Welcome to Task List CLI") print("These are the actions you can take:") print_single_row_of_stars() list_options() -run_cli() \ No newline at end of file +run_cli() diff --git a/cli/task_list.py b/cli/task_list.py index 137f3fa06..ebdae2f73 100644 --- a/cli/task_list.py +++ b/cli/task_list.py @@ -2,58 +2,56 @@ url = "http://localhost:5000" + def parse_response(response): if response.status_code >= 400: return None - + return response.json()["task"] + def create_task(title, description, completed_at=None): query_params = { "title": title, "description": description, - "completed_at": completed_at + "completed_at": completed_at, } - response = requests.post(url+"/tasks",json=query_params) + response = requests.post(url + "/tasks", json=query_params) return parse_response(response) + def list_tasks(): - response = requests.get(url+"/tasks") + response = requests.get(url + "/tasks") return response.json() + def get_task(id): - response = requests.get(url+f"/tasks/{id}") + response = requests.get(url + f"/tasks/{id}") if response.status_code != 200: return None - + return parse_response(response) -def update_task(id,title,description): - query_params = { - "title": title, - "description": description - } +def update_task(id, title, description): + + query_params = {"title": title, "description": description} - response = requests.put( - url+f"/tasks/{id}", - json=query_params - ) + response = requests.put(url + f"/tasks/{id}", json=query_params) return parse_response(response) + def delete_task(id): - response = requests.delete(url+f"/tasks/{id}") + response = requests.delete(url + f"/tasks/{id}") return response.json() + def mark_complete(id): - response = requests.patch(url+f"/tasks/{id}/mark_complete") + response = requests.patch(url + f"/tasks/{id}/mark_complete") return parse_response(response) + def mark_incomplete(id): - response = requests.patch(url+f"/tasks/{id}/mark_incomplete") + response = requests.patch(url + f"/tasks/{id}/mark_incomplete") return parse_response(response) - - - - diff --git a/migrations/env.py b/migrations/env.py index 4c9709271..d004741b2 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -12,32 +12,31 @@ # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) -logger = logging.getLogger('alembic.env') +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() + return current_app.extensions["migrate"].db.get_engine() except (TypeError, AttributeError): # this works with Flask-SQLAlchemy>=3 - return current_app.extensions['migrate'].db.engine + return current_app.extensions["migrate"].db.engine def get_engine_url(): try: - return get_engine().url.render_as_string(hide_password=False).replace( - '%', '%%') + return get_engine().url.render_as_string(hide_password=False).replace("%", "%%") except AttributeError: - return str(get_engine().url).replace('%', '%%') + 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 +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: @@ -46,7 +45,7 @@ def get_engine_url(): def get_metadata(): - if hasattr(target_db, 'metadatas'): + if hasattr(target_db, "metadatas"): return target_db.metadatas[None] return target_db.metadata @@ -64,9 +63,7 @@ def run_migrations_offline(): """ url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, target_metadata=get_metadata(), literal_binds=True - ) + context.configure(url=url, target_metadata=get_metadata(), literal_binds=True) with context.begin_transaction(): context.run_migrations() @@ -84,13 +81,13 @@ def run_migrations_online(): # 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): + if getattr(config.cmd_opts, "autogenerate", False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] - logger.info('No changes in schema detected.') + logger.info("No changes in schema detected.") - conf_args = current_app.extensions['migrate'].configure_args + 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 @@ -98,9 +95,7 @@ def process_revision_directives(context, revision, directives): with connectable.connect() as connection: context.configure( - connection=connection, - target_metadata=get_metadata(), - **conf_args + connection=connection, target_metadata=get_metadata(), **conf_args ) with context.begin_transaction(): diff --git a/migrations/versions/61d7672dd37e_added_model_goal.py b/migrations/versions/61d7672dd37e_added_model_goal.py index 2787044c9..b39554161 100644 --- a/migrations/versions/61d7672dd37e_added_model_goal.py +++ b/migrations/versions/61d7672dd37e_added_model_goal.py @@ -5,28 +5,29 @@ Create Date: 2025-05-10 21:32:03.541894 """ + from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '61d7672dd37e' -down_revision = 'e4ad941cbf09' +revision = "61d7672dd37e" +down_revision = "e4ad941cbf09" 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)) + 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') + with op.batch_alter_table("goal", schema=None) as batch_op: + batch_op.drop_column("title") # ### end Alembic commands ### diff --git a/migrations/versions/8cf3c04ce4b6_migration_added_one_to_many_.py b/migrations/versions/8cf3c04ce4b6_migration_added_one_to_many_.py index 1174ae517..e4ce12fdc 100644 --- a/migrations/versions/8cf3c04ce4b6_migration_added_one_to_many_.py +++ b/migrations/versions/8cf3c04ce4b6_migration_added_one_to_many_.py @@ -7,30 +7,31 @@ Create Date: 2025-05-11 01:44:57.765674 """ + from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '8cf3c04ce4b6' -down_revision = '61d7672dd37e' +revision = "8cf3c04ce4b6" +down_revision = "61d7672dd37e" 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']) + 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') + 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/migrations/versions/e4ad941cbf09_.py b/migrations/versions/e4ad941cbf09_.py index e881558e7..be79fc534 100644 --- a/migrations/versions/e4ad941cbf09_.py +++ b/migrations/versions/e4ad941cbf09_.py @@ -1,16 +1,17 @@ """empty message Revision ID: e4ad941cbf09 -Revises: +Revises: Create Date: 2025-05-09 15:45:48.985330 """ + from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = 'e4ad941cbf09' +revision = "e4ad941cbf09" down_revision = None branch_labels = None depends_on = None @@ -18,22 +19,24 @@ 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( + "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') + 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') + op.drop_table("task") + op.drop_table("goal") # ### end Alembic commands ### diff --git a/tests/conftest.py b/tests/conftest.py index a01499583..bd579a594 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,12 +10,13 @@ load_dotenv() + @pytest.fixture def app(): # create the app with a test configuration test_config = { "TESTING": True, - "SQLALCHEMY_DATABASE_URI": os.environ.get('SQLALCHEMY_TEST_DATABASE_URI') + "SQLALCHEMY_DATABASE_URI": os.environ.get("SQLALCHEMY_TEST_DATABASE_URI"), } app = create_app(test_config) @@ -42,9 +43,11 @@ def client(app): # This fixture creates a task and saves it in the database @pytest.fixture def one_task(app): - new_task = Task(title="Go on my daily walk 🏞", - description="Notice something new every day", - completed_at=None) + new_task = Task( + title="Go on my daily walk 🏞", + description="Notice something new every day", + completed_at=None, + ) db.session.add(new_task) db.session.commit() @@ -55,17 +58,15 @@ def one_task(app): # them in the database @pytest.fixture def three_tasks(app): - db.session.add_all([ - Task(title="Water the garden 🌷", - description="", - completed_at=None), - Task(title="Answer forgotten email 📧", - description="", - completed_at=None), - Task(title="Pay my outstanding tickets 😭", - description="", - completed_at=None) - ]) + db.session.add_all( + [ + Task(title="Water the garden 🌷", description="", completed_at=None), + Task(title="Answer forgotten email 📧", description="", completed_at=None), + Task( + title="Pay my outstanding tickets 😭", description="", completed_at=None + ), + ] + ) db.session.commit() @@ -75,9 +76,11 @@ def three_tasks(app): # valid completed_at date @pytest.fixture def completed_task(app): - new_task = Task(title="Go on my daily walk 🏞", - description="Notice something new every day", - completed_at=datetime.now()) + new_task = Task( + title="Go on my daily walk 🏞", + description="Notice something new every day", + completed_at=datetime.now(), + ) db.session.add(new_task) db.session.commit() @@ -104,4 +107,4 @@ def one_task_belongs_to_one_goal(app, one_goal, one_task): task = db.session.scalar(task_query) goal = db.session.scalar(goal_query) goal.tasks.append(task) - db.session.commit() \ No newline at end of file + db.session.commit() diff --git a/tests/test_wave_01.py b/tests/test_wave_01.py index 5a10fad5a..36c21d92c 100644 --- a/tests/test_wave_01.py +++ b/tests/test_wave_01.py @@ -28,7 +28,7 @@ def test_get_tasks_one_saved_tasks(client, one_task): "id": 1, "title": "Go on my daily walk 🏞", "description": "Notice something new every day", - "is_complete": False + "is_complete": False, } ] @@ -47,7 +47,7 @@ def test_get_task(client, one_task): "id": 1, "title": "Go on my daily walk 🏞", "description": "Notice something new every day", - "is_complete": False + "is_complete": False, } } @@ -71,10 +71,13 @@ 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 = client.post( + "/tasks", + json={ + "title": "A Brand New Task", + "description": "Test Description", + }, + ) response_body = response.get_json() # Assert @@ -85,10 +88,10 @@ def test_create_task(client): "id": 1, "title": "A Brand New Task", "description": "Test Description", - "is_complete": False + "is_complete": False, } } - + query = db.select(Task).where(Task.id == 1) new_task = db.session.scalar(query) @@ -101,10 +104,13 @@ 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 = client.put( + "/tasks/1", + json={ + "title": "Updated Task Title", + "description": "Updated Test Description", + }, + ) # Assert assert response.status_code == 204 @@ -120,16 +126,19 @@ 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 = 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 response_body == {"details": "Task id 1 not found"} - + # raise Exception("Complete test with assertion about response body") # ***************************************************************** # **Complete test with assertion about response body*************** @@ -169,32 +178,24 @@ 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 = 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 db.session.scalars(db.select(Task)).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 = 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 db.session.scalars(db.select(Task)).all() == [] \ No newline at end of file + assert response_body == {"details": "Invalid data"} + assert db.session.scalars(db.select(Task)).all() == [] diff --git a/tests/test_wave_02.py b/tests/test_wave_02.py index 651e3aebd..e8ad1599c 100644 --- a/tests/test_wave_02.py +++ b/tests/test_wave_02.py @@ -15,17 +15,20 @@ def test_get_tasks_sorted_asc(client, three_tasks): "id": 2, "title": "Answer forgotten email 📧", "description": "", - "is_complete": False}, + "is_complete": False, + }, { "id": 3, "title": "Pay my outstanding tickets 😭", "description": "", - "is_complete": False}, + "is_complete": False, + }, { "id": 1, "title": "Water the garden 🌷", "description": "", - "is_complete": False} + "is_complete": False, + }, ] @@ -43,15 +46,18 @@ def test_get_tasks_sorted_desc(client, three_tasks): "description": "", "id": 1, "is_complete": False, - "title": "Water the garden 🌷"}, + "title": "Water the garden 🌷", + }, { "description": "", "id": 3, "is_complete": False, - "title": "Pay my outstanding tickets 😭"}, + "title": "Pay my outstanding tickets 😭", + }, { "description": "", "id": 2, "is_complete": False, - "title": "Answer forgotten email 📧"}, + "title": "Answer forgotten email 📧", + }, ] diff --git a/tests/test_wave_03.py b/tests/test_wave_03.py index c04af5ceb..64539672f 100644 --- a/tests/test_wave_03.py +++ b/tests/test_wave_03.py @@ -29,7 +29,7 @@ def test_mark_complete_on_incomplete_task(client, one_task): # Assert assert response.status_code == 204 - + query = db.select(Task).where(Task.id == 1) assert db.session.scalar(query).completed_at @@ -38,7 +38,6 @@ def test_mark_complete_on_incomplete_task(client, one_task): def test_mark_incomplete_on_complete_task(client, completed_task): # Act response = client.patch("/tasks/1/mark_incomplete") - # Assert assert response.status_code == 204 @@ -66,7 +65,6 @@ def test_mark_complete_on_completed_task(client, completed_task): # Act response = client.patch("/tasks/1/mark_complete") - # Assert assert response.status_code == 204 @@ -74,6 +72,7 @@ def test_mark_complete_on_completed_task(client, completed_task): query = db.select(Task).where(Task.id == 1) assert db.session.scalar(query).completed_at + # @pytest.mark.skip(reason="No way to test this feature yet") def test_mark_incomplete_on_incomplete_task(client, one_task): # Act @@ -95,7 +94,7 @@ def test_mark_complete_missing_task(client): # Assert assert response.status_code == 404 assert response_body == {"details": "Task id 1 not found"} - + # raise Exception("Complete test with assertion about response body") # ***************************************************************** # **Complete test with assertion about response body*************** diff --git a/tests/test_wave_05.py b/tests/test_wave_05.py index 0d776d2d0..9d3fa2fe5 100644 --- a/tests/test_wave_05.py +++ b/tests/test_wave_05.py @@ -2,6 +2,7 @@ from app.models.goal import Goal from app.db import db + # @pytest.mark.skip(reason="No way to test this feature yet") def test_get_goals_no_saved_goals(client): # Act @@ -22,13 +23,7 @@ def test_get_goals_one_saved_goal(client, one_goal): # Assert assert response.status_code == 200 assert len(response_body) == 1 - assert response_body == [ - { - "id": 1, - "title": "Build a habit of going outside daily" - } - ] - + assert response_body == [{"id": 1, "title": "Build a habit of going outside daily"}] # @pytest.mark.skip(reason="No way to test this feature yet") @@ -41,10 +36,7 @@ def test_get_goal(client, one_goal): assert response.status_code == 200 assert "goal" in response_body assert response_body == { - "goal": { - "id": 1, - "title": "Build a habit of going outside daily" - } + "goal": {"id": 1, "title": "Build a habit of going outside daily"} } @@ -63,23 +55,17 @@ def test_get_goal_not_found(client): assert response_body == {"details": "Goal id 1 not found"} # ---- Complete Test ---- + # @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" - }) + response = client.post("/goals", json={"title": "My New Goal"}) response_body = response.get_json() # Assert assert response.status_code == 201 assert "goal" in response_body - assert response_body == { - "goal": { - "id": 1, - "title": "My New Goal" - } - } + assert response_body == {"goal": {"id": 1, "title": "My New Goal"}} # @pytest.mark.skip(reason="test to be completed by student") @@ -96,17 +82,12 @@ def test_update_goal(client, one_goal): assert response.status_code == 204 # assertion 2 goes here assert goal.title == "Updated Goal Title" - # assertion 3 goes here + # assertion 3 goes here assert goal.id == 1 get_response = client.get("/goals/1") get_body = get_response.get_json() - assert get_body == { - "goal": { - "id": 1, - "title": "Updated Goal Title" - } - } + assert get_body == {"goal": {"id": 1, "title": "Updated Goal Title"}} # ---- Complete Assertions Here ---- @@ -115,9 +96,12 @@ def test_update_goal_not_found(client): # raise Exception("Complete test") # Act # ---- Complete Act Here ---- - response = client.put("/goals/1", json={ - "title": "Updated Goal Title", - }) + response = client.put( + "/goals/1", + json={ + "title": "Updated Goal Title", + }, + ) response_body = response.get_json() # Assert # ---- Complete Assertions Here ---- @@ -142,7 +126,7 @@ def test_delete_goal(client, one_goal): response_body = response.get_json() assert response_body == {"details": "Goal id 1 not found"} - + # raise Exception("Complete test with assertion about response body") # ***************************************************************** # **Complete test with assertion about response body*************** @@ -166,6 +150,7 @@ def test_delete_goal_not_found(client): assert response.status_code == 404 assert response_body == {"details": "Goal id 1 not found"} + # @pytest.mark.skip(reason="No way to test this feature yet") def test_create_goal_missing_title(client): # Act @@ -174,6 +159,4 @@ def test_create_goal_missing_title(client): # 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 74c5f4a17..0241e7fa2 100644 --- a/tests/test_wave_06.py +++ b/tests/test_wave_06.py @@ -6,19 +6,14 @@ # @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 = 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 - assert response_body == { - "id": 1, - "task_ids": [1, 2, 3] - } + assert response_body == {"id": 1, "task_ids": [1, 2, 3]} # Check that Goal was updated in the db query = db.select(Goal).where(Goal.id == 1) @@ -26,21 +21,18 @@ 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): +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": [2, 4] - }) + response = client.post("/goals/1/tasks", json={"task_ids": [2, 4]}) response_body = response.get_json() # Assert assert response.status_code == 200 assert "id" in response_body assert "task_ids" in response_body - assert response_body == { - "id": 1, - "task_ids": [2, 4] - } + assert response_body == {"id": 1, "task_ids": [2, 4]} query = db.select(Goal).where(Goal.id == 1) assert len(db.session.scalar(query).tasks) == 2 @@ -53,7 +45,7 @@ def test_get_tasks_for_specific_goal_no_goal(client): # Assert assert response.status_code == 404 - assert response_body == {'details': 'Goal id 1 not found'} + assert response_body == {"details": "Goal id 1 not found"} # raise Exception("Complete test with assertion about response body") # ***************************************************************** # **Complete test with assertion about response body*************** @@ -73,7 +65,7 @@ def test_get_tasks_for_specific_goal_no_tasks(client, one_goal): assert response_body == { "id": 1, "title": "Build a habit of going outside daily", - "tasks": [] + "tasks": [], } @@ -96,9 +88,9 @@ def test_get_tasks_for_specific_goal(client, one_task_belongs_to_one_goal): "goal_id": 1, "title": "Go on my daily walk 🏞", "description": "Notice something new every day", - "is_complete": False + "is_complete": False, } - ] + ], } @@ -116,6 +108,6 @@ def test_get_task_includes_goal_id(client, one_task_belongs_to_one_goal): "goal_id": 1, "title": "Go on my daily walk 🏞", "description": "Notice something new every day", - "is_complete": False + "is_complete": False, } } From 14d2e2a7dbe0c0d17b5684c7aeafdca4425503ca Mon Sep 17 00:00:00 2001 From: Marina Adams Date: Wed, 28 May 2025 02:04:38 -0700 Subject: [PATCH 13/13] N --- app/routes/route_utilities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 98c663924..aa8e64bf5 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -39,7 +39,7 @@ def get_models_with_filters(cls, filters=None): sort_param = None if filters: - filters_dict = dict(filters) # Convert ImmutableMultiDict to dict + filters_dict = dict(filters) sort_param = filters_dict.pop("sort", None) for attribute, value in filters_dict.items():