From 283b55fd7132eff6152d9d050097e91478938d90 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Fri, 2 May 2025 00:25:57 +0300 Subject: [PATCH 01/24] complete setup & model --- README.md | 3 +- ada-project-docs/wave_01.md | 2 + app/models/task.py | 21 ++++ migrations/README | 1 + migrations/alembic.ini | 50 ++++++++ migrations/env.py | 113 ++++++++++++++++++ migrations/script.py.mako | 24 ++++ .../b303441af36e_create_task_model.py | 39 ++++++ 8 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/b303441af36e_create_task_model.py diff --git a/README.md b/README.md index 85e1c0f69..ab4054b1d 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ At submission time, no matter where you are, submit the project via Learn. This project is designed to fulfill the features described in detail in each wave. The tests are meant to only guide your development. 1. [Setup](ada-project-docs/setup.md) -1. [Testing](ada-project-docs/testing.md) +1. [Testing](ada-project-docs/testing.md) +code coverage: pytest --cov=app --cov-report html --cov-report term 1. [Wave 1: CRUD for one model](ada-project-docs/wave_01.md) 1. [Wave 2: Using query params](ada-project-docs/wave_02.md) 1. [Wave 3: Creating custom endpoints](ada-project-docs/wave_03.md) diff --git a/ada-project-docs/wave_01.md b/ada-project-docs/wave_01.md index a9a1d02f7..42206b468 100644 --- a/ada-project-docs/wave_01.md +++ b/ada-project-docs/wave_01.md @@ -250,3 +250,5 @@ If the HTTP request is missing `description`, we should also get this response: "details": "Invalid data" } ``` + +make sure to run db migrate and upgrade after wave 1! \ No newline at end of file diff --git a/app/models/task.py b/app/models/task.py index 5d99666a4..387e0695e 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1,5 +1,26 @@ from sqlalchemy.orm import Mapped, mapped_column from ..db import db +from datetime import datetime class Task(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + title: Mapped[str] = mapped_column(nullable=False) + description: Mapped[str] + completed_at: Mapped[datetime] = mapped_column(nullable=True) + + def to_dict(self): + task_as_dict = {} + task_as_dict["id"] = self.id + task_as_dict["title"] = self.title + task_as_dict["description"] = self.description + task_as_dict["completed_at"] = self.completed_at + + return task_as_dict + + @classmethod + def from_dict(cls, task_data): + new_task = cls(title=task_data["title"], + description=task_data["description"], + completed_at=task_data["completed_at"]) + + return new_task diff --git a/migrations/README b/migrations/README new file mode 100644 index 000000000..0e0484415 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 000000000..ec9d45c26 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 000000000..4c9709271 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 000000000..2c0156303 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/b303441af36e_create_task_model.py b/migrations/versions/b303441af36e_create_task_model.py new file mode 100644 index 000000000..e6136bef2 --- /dev/null +++ b/migrations/versions/b303441af36e_create_task_model.py @@ -0,0 +1,39 @@ +"""create task model + +Revision ID: b303441af36e +Revises: +Create Date: 2025-05-02 00:22:15.246418 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b303441af36e' +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 ### From 7282fa3e77806c650536baf855b79b077c9c8eab Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Fri, 2 May 2025 00:33:55 +0300 Subject: [PATCH 02/24] create validate_model helper function --- app/routes/route_utilities.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 app/routes/route_utilities.py diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py new file mode 100644 index 000000000..9b043364a --- /dev/null +++ b/app/routes/route_utilities.py @@ -0,0 +1,19 @@ +from flask import abort, make_response +from ..db import db + +def validate_model(cls, model_id): + try: + model_id = int(model_id) + except: + response = {"message": f"{cls.__name__} {model_id} is 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__} {model_id} is not found"} + abort(make_response(response, 404)) + + return model + From 8c89995a6f7b56d01e0243fb7c2f9cdb68f9a7c8 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Fri, 2 May 2025 00:59:16 +0300 Subject: [PATCH 03/24] add second helper function to create model from dict --- app/routes/route_utilities.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 9b043364a..528328d09 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -17,3 +17,9 @@ def validate_model(cls, model_id): return model +# review this function and use for routes +def create_model_from_dict(cls, data): + model = cls.from_dict(data) + db.session.add(model) + db.session.commit() + return model \ No newline at end of file From 8bbf41cdfbb856f20d19fc92855fa83bc162189f Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Fri, 2 May 2025 01:32:54 +0300 Subject: [PATCH 04/24] create post route --- app/routes/route_utilities.py | 2 +- app/routes/task_routes.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 528328d09..63c0ac896 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -17,7 +17,7 @@ def validate_model(cls, model_id): return model -# review this function and use for routes +# review this function and use for post route def create_model_from_dict(cls, data): model = cls.from_dict(data) db.session.add(model) diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 3aae38d49..d4d46c876 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1 +1,29 @@ -from flask import Blueprint \ No newline at end of file +from flask import Blueprint, abort, make_response, request, Response +from app.models.task import Task +from .route_utilities import validate_model, create_model_from_dict +from ..db import db + +bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") + +# create a new task +@bp.post("") +def create_task(): + request_body = request.get_json() + + try: + new_task = create_model_from_dict(Task, request_body) + + except KeyError as error: + response = {"message": f"Invalid request: missing {error.args[0]}"} + abort(make_response(response, 400)) + + return new_task.to_dict(), 201 + + +# read all tasks + +# read one task + +# update task + +# delete task From 62975b7fb4119ae8999d2e33050dd82f266a342e Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Fri, 2 May 2025 01:59:16 +0300 Subject: [PATCH 05/24] register blueprint and establish routes --- app/__init__.py | 2 ++ app/models/task.py | 15 ++++++------- app/routes/task_routes.py | 46 +++++++++++++++++++++++++++++++++++++-- tests/test_wave_01.py | 4 ++-- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 3c581ceeb..617440a9b 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 387e0695e..9844dd04b 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -9,18 +9,17 @@ class Task(db.Model): completed_at: Mapped[datetime] = mapped_column(nullable=True) def to_dict(self): - task_as_dict = {} - task_as_dict["id"] = self.id - task_as_dict["title"] = self.title - task_as_dict["description"] = self.description - task_as_dict["completed_at"] = self.completed_at - - return task_as_dict + 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): new_task = cls(title=task_data["title"], description=task_data["description"], - completed_at=task_data["completed_at"]) + completed_at=task_data.get("completed_at")) return new_task diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index d4d46c876..76e2c2b30 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -16,11 +16,53 @@ def create_task(): except KeyError as error: response = {"message": f"Invalid request: missing {error.args[0]}"} abort(make_response(response, 400)) - - return new_task.to_dict(), 201 + return {"task": new_task.to_dict()}, 201 # read all tasks +@bp.get("") +def get_all_tasks(): + # create a basic select query without any filtering + query = db.select(Task) + + tasks = db.session.scalars(query.order_by(Task.id)) + + tasks_response = [] + for task in tasks: + tasks_response.append(task.to_dict()) + # ^ can turn into list comprehension: + # tasks_response = [task.to_dict() for task in tasks] + + return tasks_response + + # # If we have a `title` query parameter, we can add on to the query object + # title_param = request.args.get("title") + # if title_param: + # # Match the title_param exactly, including capitalization + # # query = query.where(Book.title == title_param) + + # # If we want to allow partial matches, we can use the % wildcard with `like()` + # # If `title_param` contains "Great", the code below will match + # # both "The Great Gatsby" and "Great Expectations" + # # query = query.where(Book.title.like(f"%{title_param}%")) + + # # If we want to allow searching case-insensitively, + # # we can use ilike instead of like + # query = query.where(Book.title.ilike(f"%{title_param}%")) + + # # If we have other query parameters, we can continue adding to the query. + # # As we did above, we must reassign the `query`` variable to capture the new clause we are adding. + # # If we don't reassign `query``, we are calling the `where` function but are not saving the resulting filter + # description_param = request.args.get("description") + # if description_param: + # # In case there are books with similar titles, we can also filter by description + # query = query.where(Book.description.ilike(f"%{description_param}%")) + + # books = db.session.scalars(query.order_by(Book.id)) + # # We could also write the line above as: + # # books = db.session.execute(query).scalars() + + # read one task diff --git a/tests/test_wave_01.py b/tests/test_wave_01.py index 55475db79..eb9fa6862 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") @@ -67,7 +67,7 @@ def test_get_task_not_found(client): # ***************************************************************** -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_create_task(client): # Act response = client.post("/tasks", json={ From 7f2dfb4b9ee8afc050a3f12eedb920337795b9d9 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Fri, 2 May 2025 02:30:23 +0300 Subject: [PATCH 06/24] passed tests wave 1 --- app/routes/task_routes.py | 28 +++++++++++++++++++++++--- tests/test_wave_01.py | 41 +++++++++++++-------------------------- 2 files changed, 39 insertions(+), 30 deletions(-) diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 76e2c2b30..aac5cb747 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -14,7 +14,7 @@ def create_task(): new_task = create_model_from_dict(Task, request_body) except KeyError as error: - response = {"message": f"Invalid request: missing {error.args[0]}"} + response = {"details": "Invalid data"} abort(make_response(response, 400)) return {"task": new_task.to_dict()}, 201 @@ -62,10 +62,32 @@ def get_all_tasks(): # # We could also write the line above as: # # books = db.session.execute(query).scalars() - - # read one task +@bp.get("/") +def get_one_task(task_id): + task = validate_model(Task, task_id) + + return {"task": task.to_dict()}, 200 # update task +@bp.put("/") +def update_one_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") # delete task +@bp.delete("/") +def delete_one_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/tests/test_wave_01.py b/tests/test_wave_01.py index eb9fa6862..1545e294f 100644 --- a/tests/test_wave_01.py +++ b/tests/test_wave_01.py @@ -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") @@ -60,12 +60,7 @@ def test_get_task_not_found(client): # Assert assert response.status_code == 404 - - raise Exception("Complete test with assertion about response body") - # ***************************************************************** - # **Complete test with assertion about response body*************** - # ***************************************************************** - + assert response_body == {"message": "Task 1 is not found"} # @pytest.mark.skip(reason="No way to test this feature yet") def test_create_task(client): @@ -97,7 +92,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 +112,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 +123,10 @@ def test_update_task_not_found(client): # Assert assert response.status_code == 404 + assert response_body == {"message": "Task 1 is 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 +137,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,16 +145,12 @@ def test_delete_task_not_found(client): # Assert assert response.status_code == 404 - - raise Exception("Complete test with assertion about response body") - # ***************************************************************** - # **Complete test with assertion about response body*************** - # ***************************************************************** - + assert response_body == {"message": "Task 1 is not found"} + 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 +167,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 ea895859cf765de3758c6226f92dbd9339e51943 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Fri, 2 May 2025 02:32:36 +0300 Subject: [PATCH 07/24] fix typo --- ada-project-docs/wave_01.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/ada-project-docs/wave_01.md b/ada-project-docs/wave_01.md index 42206b468..a9a1d02f7 100644 --- a/ada-project-docs/wave_01.md +++ b/ada-project-docs/wave_01.md @@ -250,5 +250,3 @@ If the HTTP request is missing `description`, we should also get this response: "details": "Invalid data" } ``` - -make sure to run db migrate and upgrade after wave 1! \ No newline at end of file From d36c66724c98df04e72983427f50b85924df8271 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Fri, 2 May 2025 18:38:26 +0300 Subject: [PATCH 08/24] add sort param --- app/routes/task_routes.py | 41 ++++++++++++++++++--------------------- tests/test_wave_02.py | 4 ++-- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index aac5cb747..ce33a6182 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -25,31 +25,28 @@ def get_all_tasks(): # create a basic select query without any filtering query = db.select(Task) - tasks = db.session.scalars(query.order_by(Task.id)) + sort = request.args.get("sort") - tasks_response = [] - for task in tasks: - tasks_response.append(task.to_dict()) - # ^ can turn into list comprehension: - # tasks_response = [task.to_dict() for task in tasks] + if sort == "asc": + query = query.order_by(Task.title.asc()) + elif sort == "desc": + query = query.order_by(Task.title.desc()) - return tasks_response + tasks = db.session.scalars(query) - # # If we have a `title` query parameter, we can add on to the query object - # title_param = request.args.get("title") - # if title_param: - # # Match the title_param exactly, including capitalization - # # query = query.where(Book.title == title_param) + tasks_response = [task.to_dict() for task in tasks] + + return tasks_response - # # If we want to allow partial matches, we can use the % wildcard with `like()` - # # If `title_param` contains "Great", the code below will match - # # both "The Great Gatsby" and "Great Expectations" - # # query = query.where(Book.title.like(f"%{title_param}%")) - # # If we want to allow searching case-insensitively, - # # we can use ilike instead of like - # query = query.where(Book.title.ilike(f"%{title_param}%")) + #if you want to search by title or description: + # if title_param: + # query = query.where(Task.title.ilike(f"%{title_param}%")) + # tasks = db.session.scalars(query.order_by(Task.title)) + # # # We could also write the line above as: + # # # books = db.session.execute(query).scalars() + # # If we have other query parameters, we can continue adding to the query. # # As we did above, we must reassign the `query`` variable to capture the new clause we are adding. # # If we don't reassign `query``, we are calling the `where` function but are not saving the resulting filter @@ -58,9 +55,9 @@ def get_all_tasks(): # # In case there are books with similar titles, we can also filter by description # query = query.where(Book.description.ilike(f"%{description_param}%")) - # books = db.session.scalars(query.order_by(Book.id)) - # # We could also write the line above as: - # # books = db.session.execute(query).scalars() + # tasks = db.session.scalars(query.order_by(Task.id)) + + # read one task @bp.get("/") 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 4e9bd9aca22f184cf091a1429bdb92d1072e11be Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Fri, 2 May 2025 19:38:29 +0300 Subject: [PATCH 09/24] complete patch endroutes in wave 3 --- app/routes/task_routes.py | 21 +++++++++++++++++++++ tests/test_wave_03.py | 25 +++++++++---------------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index ce33a6182..cf38ec368 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1,6 +1,7 @@ from flask import Blueprint, abort, make_response, request, Response from app.models.task import Task from .route_utilities import validate_model, create_model_from_dict +from datetime import datetime, timezone from ..db import db bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") @@ -88,3 +89,23 @@ def delete_one_task(task_id): db.session.commit() return Response(status=204, mimetype="application/json") + +@bp.patch("//mark_complete") +def mark_test_complete(task_id): + task = validate_model(Task, task_id) + + task.completed_at = datetime.now(timezone.utc) + + db.session.commit() + + return Response(status=204, mimetype="application/json") + +@bp.patch("//mark_incomplete") +def mark_task_incomplete(task_id): + task = validate_model(Task, task_id) + + task.completed_at = None + + db.session.commit() + + return Response(status=204, mimetype="application/json") diff --git a/tests/test_wave_03.py b/tests/test_wave_03.py index d7d441695..3bab34ee6 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,11 @@ def test_mark_complete_missing_task(client): # Assert assert response.status_code == 404 + assert {'message': 'Task 1 is not found'} + # OR: assert response_body == {'message': 'Task 1 is 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 +106,4 @@ def test_mark_incomplete_missing_task(client): # Assert assert response.status_code == 404 - - raise Exception("Complete test with assertion about response body") - # ***************************************************************** - # **Complete test with assertion about response body*************** - # ***************************************************************** + assert {'message': 'Task 1 is invalid'} \ No newline at end of file From 25612bf06bd9b0ee76519aea417550806a301704 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Sat, 3 May 2025 05:37:08 +0300 Subject: [PATCH 10/24] complete slack bot message --- app/routes/task_routes.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index cf38ec368..e4354f825 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1,8 +1,12 @@ from flask import Blueprint, abort, make_response, request, Response +from dotenv import load_dotenv from app.models.task import Task from .route_utilities import validate_model, create_model_from_dict from datetime import datetime, timezone +import os +import requests from ..db import db +load_dotenv() bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") @@ -91,13 +95,37 @@ def delete_one_task(task_id): return Response(status=204, mimetype="application/json") @bp.patch("//mark_complete") -def mark_test_complete(task_id): +def mark_task_complete(task_id): task = validate_model(Task, task_id) task.completed_at = datetime.now(timezone.utc) db.session.commit() + # send slack message + slack_token = os.environ.get("SLACK_BOT_TOKEN") + slack_channel = os.environ.get("SLACK_CHANNEL") + + if slack_token and slack_channel: + slack_message = { + "channel": slack_channel, + "text": f"Someone just completed the task {task.title}" + } + + headers = { + "Authorization": f"Bearer {slack_token}", + "Content-Type": "application/json" + } + + # sends request to slack + slack_response = requests.post("https://slack.com/api/chat.postMessage", json=slack_message, headers=headers) + + # #optional error handling + # if not slack_response.ok: + # print("Slack API error": slack_response.status_code, slack_response.text) + + # can refactor here into a helper function for the slack message + return Response(status=204, mimetype="application/json") @bp.patch("//mark_incomplete") @@ -109,3 +137,4 @@ def mark_task_incomplete(task_id): db.session.commit() return Response(status=204, mimetype="application/json") + From a928f5667fe1f0e306d5b7f5138097329fb3eef2 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Thu, 8 May 2025 13:14:41 +0200 Subject: [PATCH 11/24] create post and get routes for goal model --- app/__init__.py | 11 ++++++ app/models/goal.py | 12 +++++++ app/routes/goal_routes.py | 34 ++++++++++++++++++- app/routes/route_utilities.py | 4 ++- app/routes/task_routes.py | 2 +- .../versions/31f79ab68e45_adds_goalsmodel.py | 32 +++++++++++++++++ 6 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 migrations/versions/31f79ab68e45_adds_goalsmodel.py diff --git a/app/__init__.py b/app/__init__.py index 617440a9b..097d88473 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,15 @@ def create_app(config=None): # Register Blueprints here app.register_blueprint(tasks_bp) + app.register_blueprint(goals_bp) + + # Register Blueprints here + app.register_blueprint(tasks_bp) + app.register_blueprint(goals_bp) + + # # DEBUG: print all routes + # print("\nšŸ“Œ Registered routes:") + # for rule in app.url_map.iter_rules(): + # print(f"{rule.endpoint}: {rule.rule} [{', '.join(rule.methods)}]") return app diff --git a/app/models/goal.py b/app/models/goal.py index 44282656b..aadb96973 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -3,3 +3,15 @@ class Goal(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + title: Mapped[str] #= mapped_column(nullable=False) + + def to_dict(self): + return { + "id": self.id, + "title": self.title + } + + @classmethod + def from_dict(cls, goal_data): + new_goal = cls(title=goal_data["title"]) + return new_goal \ No newline at end of file diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 3aae38d49..81b5dea12 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -1 +1,33 @@ -from flask import Blueprint \ No newline at end of file +from flask import Blueprint, abort, make_response, request, Response +from dotenv import load_dotenv +from app.models.goal import Goal +from .route_utilities import validate_model, create_model_from_dict +import os +import requests +from ..db import db +load_dotenv() + + +bp = Blueprint("goals_bp", __name__, url_prefix="/goals") + +# create a new goal +@bp.post("") +def create_goal(): + request_body = request.get_json() + + try: + new_goal = create_model_from_dict(Goal, request_body) + except KeyError as e: + response = {"details": "Invalid data"} + abort(make_response(response, 400)) + + return {"goal": new_goal.to_dict()}, 201 + +# get all goals +@bp.get("") +def get_all_goals(): + query = db.select(Goal) + + goals = db.session.scalars(query) + + return [goal.to_dict() for goal in goals] \ No newline at end of file diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 63c0ac896..15c3e1bf8 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -22,4 +22,6 @@ def create_model_from_dict(cls, data): model = cls.from_dict(data) db.session.add(model) db.session.commit() - return model \ No newline at end of file + return model + +# what about get model by filter? \ No newline at end of file diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index e4354f825..d53ed2afd 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -10,7 +10,7 @@ bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") -# create a new task +# create a new task ... refactor @bp.post("") def create_task(): request_body = request.get_json() diff --git a/migrations/versions/31f79ab68e45_adds_goalsmodel.py b/migrations/versions/31f79ab68e45_adds_goalsmodel.py new file mode 100644 index 000000000..40f3a91ca --- /dev/null +++ b/migrations/versions/31f79ab68e45_adds_goalsmodel.py @@ -0,0 +1,32 @@ +"""adds goalsmodel + +Revision ID: 31f79ab68e45 +Revises: b303441af36e +Create Date: 2025-05-08 12:42:37.977182 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '31f79ab68e45' +down_revision = 'b303441af36e' +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 d8803070123595a9bd4f8052669a89d2480a9bbf Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Thu, 8 May 2025 13:23:50 +0200 Subject: [PATCH 12/24] add get one goal --- app/__init__.py | 9 --------- app/routes/goal_routes.py | 9 ++++++++- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 097d88473..6e447bbf7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -23,13 +23,4 @@ def create_app(config=None): app.register_blueprint(tasks_bp) app.register_blueprint(goals_bp) - # Register Blueprints here - app.register_blueprint(tasks_bp) - app.register_blueprint(goals_bp) - - # # DEBUG: print all routes - # print("\nšŸ“Œ Registered routes:") - # for rule in app.url_map.iter_rules(): - # print(f"{rule.endpoint}: {rule.rule} [{', '.join(rule.methods)}]") - return app diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 81b5dea12..e5fdbd72e 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -30,4 +30,11 @@ def get_all_goals(): goals = db.session.scalars(query) - return [goal.to_dict() for goal in goals] \ No newline at end of file + return [goal.to_dict() for goal in goals] + +# get one goal +@bp.get("/") +def get_one_goal(goal_id): + goal = validate_model(Goal, goal_id) + + return {"goal": goal.to_dict()}, 200 \ No newline at end of file From a3eaa64d7931a633611355f3b1b4855420ce1e79 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Thu, 8 May 2025 13:28:27 +0200 Subject: [PATCH 13/24] update goal --- app/routes/goal_routes.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index e5fdbd72e..df8dca878 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -37,4 +37,17 @@ def get_all_goals(): def get_one_goal(goal_id): goal = validate_model(Goal, goal_id) - return {"goal": goal.to_dict()}, 200 \ No newline at end of file + return {"goal": goal.to_dict()}, 200 + +# update one goal +@bp.put("/") +def update_goal(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") \ No newline at end of file From 847886f354752a6e511a14a08c505149e3b1dc03 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Thu, 8 May 2025 13:31:43 +0200 Subject: [PATCH 14/24] delete goal --- app/routes/goal_routes.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index df8dca878..5c87a94f7 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -50,4 +50,13 @@ def update_goal(goal_id): db.session.commit() + return Response(status=204, mimetype="application/json") + +# delete one goal +@bp.delete("/") +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 From f9cdd0f67d308a0fb1566f868f0e049474d944bf Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Thu, 8 May 2025 14:20:02 +0200 Subject: [PATCH 15/24] complete wave 5 goal tests --- tests/test_wave_05.py | 82 ++++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 47 deletions(-) diff --git a/tests/test_wave_05.py b/tests/test_wave_05.py index 222d10cf0..698e64218 100644 --- a/tests/test_wave_05.py +++ b/tests/test_wave_05.py @@ -1,7 +1,9 @@ +from app.models.goal import Goal +from app.db import db import pytest -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_goals_no_saved_goals(client): # Act response = client.get("/goals") @@ -12,7 +14,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 +31,7 @@ def test_get_goals_one_saved_goal(client, one_goal): ] -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_goal(client, one_goal): # Act response = client.get("/goals/1") @@ -46,22 +48,18 @@ def test_get_goal(client, one_goal): } -@pytest.mark.skip(reason="test to be completed by student") +# @pytest.mark.skip(reason="test to be completed by student") def test_get_goal_not_found(client): pass # Act response = client.get("/goals/1") response_body = response.get_json() - raise Exception("Complete test") - # Assert - # ---- Complete Test ---- - # assertion 1 goes here - # assertion 2 goes here - # ---- Complete Test ---- + assert response.status_code == 404 + assert response_body == {"message": "Goal 1 is 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(client): # Act response = client.post("/goals", json={ @@ -80,34 +78,32 @@ def test_create_goal(client): } -@pytest.mark.skip(reason="test to be completed by student") +# @pytest.mark.skip(reason="test to be completed by student") def test_update_goal(client, one_goal): - raise Exception("Complete test") - # Act - # ---- Complete Act Here ---- + response = client.put("goals/1", json={ + "title": "This is my updated Task Title" + }) - # Assert - # ---- Complete Assertions Here ---- - # assertion 1 goes here - # assertion 2 goes here - # assertion 3 goes here - # ---- Complete Assertions Here ---- + assert response.status_code == 204 + + query = db.select(Goal).where(Goal.id == 1) + goal = db.session.scalar(query) + + assert goal.title == "This is my updated Task Title" -@pytest.mark.skip(reason="test to be completed by student") +# @pytest.mark.skip(reason="test to be completed by student") def test_update_goal_not_found(client): - raise Exception("Complete test") - # Act - # ---- Complete Act Here ---- + response = client.put("/goals/1", json={ + "title": "updated task 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 == {"message": "Goal 1 is 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") @@ -121,28 +117,20 @@ def test_delete_goal(client, one_goal): response_body = response.get_json() assert "message" in response_body - - raise Exception("Complete test with assertion about response body") - # ***************************************************************** - # **Complete test with assertion about response body*************** - # ***************************************************************** + assert {"message": "Goal 1 is not found"} -@pytest.mark.skip(reason="test to be completed by student") +# @pytest.mark.skip(reason="test to be completed by student") def test_delete_goal_not_found(client): - raise Exception("Complete test") - - # 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 == {"message": "Goal 1 is not found"} + assert db.session.scalars(db.select(Goal)).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_goal_missing_title(client): # Act response = client.post("/goals", json={}) From a24ed2f1581b27787183a9ccbf6139efee47b2bd Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Thu, 8 May 2025 14:38:45 +0200 Subject: [PATCH 16/24] sets up relationship between tasks and goal --- app/models/goal.py | 6 +++- app/models/task.py | 8 ++++- ...dds_relationship_between_tasks_and_goal.py | 34 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 migrations/versions/3db3cdec4a89_adds_relationship_between_tasks_and_goal.py diff --git a/app/models/goal.py b/app/models/goal.py index aadb96973..1f1e544c5 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -1,9 +1,13 @@ -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from .task import Task from ..db import db class Goal(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] #= mapped_column(nullable=False) + tasks: Mapped[list["Task"]] = relationship(back_populates="goal") def to_dict(self): return { diff --git a/app/models/task.py b/app/models/task.py index 9844dd04b..4538d073e 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1,4 +1,8 @@ -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import ForeignKey +from typing import Optional, TYPE_CHECKING +if TYPE_CHECKING: + from .goal import Goal from ..db import db from datetime import datetime @@ -7,6 +11,8 @@ class Task(db.Model): title: Mapped[str] = mapped_column(nullable=False) description: Mapped[str] completed_at: Mapped[datetime] = mapped_column(nullable=True) + goal_id: Mapped[Optional[int]] = mapped_column(ForeignKey("goal.id")) + goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") def to_dict(self): return { diff --git a/migrations/versions/3db3cdec4a89_adds_relationship_between_tasks_and_goal.py b/migrations/versions/3db3cdec4a89_adds_relationship_between_tasks_and_goal.py new file mode 100644 index 000000000..2acbd72d1 --- /dev/null +++ b/migrations/versions/3db3cdec4a89_adds_relationship_between_tasks_and_goal.py @@ -0,0 +1,34 @@ +"""adds relationship between tasks and goal + +Revision ID: 3db3cdec4a89 +Revises: 31f79ab68e45 +Create Date: 2025-05-08 14:38:15.240889 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3db3cdec4a89' +down_revision = '31f79ab68e45' +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 ### From bd41175c9a0a9c56c1c2ebce09b7462f8acfc355 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Thu, 8 May 2025 15:25:06 +0200 Subject: [PATCH 17/24] adds tasks to goal --- app/models/task.py | 12 +++++++++--- app/routes/goal_routes.py | 19 ++++++++++++++++++- tests/test_wave_06.py | 2 +- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/app/models/task.py b/app/models/task.py index 4538d073e..5ebc13057 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -15,17 +15,23 @@ class Task(db.Model): goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") 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: + task_dict["goal"] = self.goal.title + + return task_dict @classmethod def from_dict(cls, task_data): new_task = cls(title=task_data["title"], description=task_data["description"], - completed_at=task_data.get("completed_at")) - + completed_at=task_data.get("completed_at"), + goal_id=task_data.get("goal_id")) + return new_task diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 5c87a94f7..88252bcc7 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -1,6 +1,7 @@ from flask import Blueprint, abort, make_response, request, Response from dotenv import load_dotenv from app.models.goal import Goal +from app.models.task import Task from .route_utilities import validate_model, create_model_from_dict import os import requests @@ -59,4 +60,20 @@ def delete_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") + +# send a list of tasks to a goal +@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", []) + + for task_id in task_ids: # add helper function here + task = validate_model(Task, task_id) + task.goal_id = goal.id + + db.session.commit() + + return {"id": goal.id, "task_ids": task_ids}, 200 \ No newline at end of file diff --git a/tests/test_wave_06.py b/tests/test_wave_06.py index 0317f835a..32b0e7b7e 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={ From 5d6f4164e1b7b61b0d9bc5a098de94ca88473bb1 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Thu, 8 May 2025 16:11:43 +0200 Subject: [PATCH 18/24] gets tasks of one goal --- app/models/goal.py | 9 +++++++-- app/models/task.py | 10 +++------- app/routes/goal_routes.py | 9 ++++++++- tests/test_wave_06.py | 4 ++-- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/app/models/goal.py b/app/models/goal.py index 1f1e544c5..39c870201 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -7,13 +7,18 @@ class Goal(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] #= mapped_column(nullable=False) - tasks: Mapped[list["Task"]] = relationship(back_populates="goal") + tasks: Mapped[list["Task"]] = relationship("Task", back_populates="goal") def to_dict(self): - return { + goal_dict = { "id": self.id, "title": self.title } + + if self.tasks: + goal_dict["tasks"] = [task.to_dict() for task in self.tasks] + + return goal_dict @classmethod def from_dict(cls, goal_data): diff --git a/app/models/task.py b/app/models/task.py index 5ebc13057..3eae646f6 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -12,21 +12,17 @@ class Task(db.Model): description: Mapped[str] completed_at: Mapped[datetime] = mapped_column(nullable=True) goal_id: Mapped[Optional[int]] = mapped_column(ForeignKey("goal.id")) - goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") + goal: Mapped[Optional["Goal"]] = relationship("Goal", back_populates="tasks") def to_dict(self): - task_dict = { + return { "id": self.id, + "goal_id": self.goal_id, "title": self.title, "description": self.description, "is_complete": self.completed_at is not None } - if self.goal: - task_dict["goal"] = self.goal.title - - return task_dict - @classmethod def from_dict(cls, task_data): new_task = cls(title=task_data["title"], diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 88252bcc7..36eb81f64 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -76,4 +76,11 @@ def add_tasks_to_goal(goal_id): db.session.commit() - return {"id": goal.id, "task_ids": task_ids}, 200 \ No newline at end of file + return {"id": goal.id, "task_ids": task_ids}, 200 + +# getting tasks of one goal +@bp.get("//tasks") +def get_tasks_of_goal(goal_id): + goal = validate_model(Goal, goal_id) + + return goal.to_dict(), 200 \ No newline at end of file diff --git a/tests/test_wave_06.py b/tests/test_wave_06.py index 32b0e7b7e..1908fb60c 100644 --- a/tests/test_wave_06.py +++ b/tests/test_wave_06.py @@ -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 2bdb33cbbc94d2e2373c8a0992fd0e9ccecca79a Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Thu, 8 May 2025 20:49:29 +0200 Subject: [PATCH 19/24] gets tasks for specific goals and no goals --- app/models/goal.py | 3 ++- tests/test_wave_06.py | 11 ++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/models/goal.py b/app/models/goal.py index 39c870201..ea2b63d80 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -12,7 +12,8 @@ class Goal(db.Model): def to_dict(self): goal_dict = { "id": self.id, - "title": self.title + "title": self.title, + "tasks": self.tasks } if self.tasks: diff --git a/tests/test_wave_06.py b/tests/test_wave_06.py index 1908fb60c..dec4e8acd 100644 --- a/tests/test_wave_06.py +++ b/tests/test_wave_06.py @@ -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,11 @@ 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") - # ***************************************************************** - # **Complete test with assertion about response body*************** - # ***************************************************************** + assert response_body == {"message": "Goal 1 is 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_get_tasks_for_specific_goal_no_tasks(client, one_goal): # Act response = client.get("/goals/1/tasks") From a006f44832e746b4da3908b4c7e37e04a8871767 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Thu, 8 May 2025 21:39:56 +0200 Subject: [PATCH 20/24] testing --- app/models/goal.py | 5 +++-- app/models/task.py | 11 ++++++++--- app/routes/goal_routes.py | 3 +++ tests/test_wave_06.py | 2 +- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/models/goal.py b/app/models/goal.py index ea2b63d80..c1dcb7f85 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -13,10 +13,11 @@ def to_dict(self): goal_dict = { "id": self.id, "title": self.title, - "tasks": self.tasks + # "tasks": self.tasks } - if self.tasks: + # if self.tasks: + if hasattr(self, "tasks") and self.tasks: goal_dict["tasks"] = [task.to_dict() for task in self.tasks] return goal_dict diff --git a/app/models/task.py b/app/models/task.py index 3eae646f6..1a3722c12 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -14,15 +14,20 @@ class Task(db.Model): goal_id: Mapped[Optional[int]] = mapped_column(ForeignKey("goal.id")) goal: Mapped[Optional["Goal"]] = relationship("Goal", back_populates="tasks") - def to_dict(self): - return { + def to_dict(self, include_goal_id=False): + task_dict = { "id": self.id, - "goal_id": self.goal_id, + # "goal_id": self.goal_id, "title": self.title, "description": self.description, "is_complete": self.completed_at is not None } + if include_goal_id: + task_dict["goal_id"] = self.goal_id + + return task_dict + @classmethod def from_dict(cls, task_data): new_task = cls(title=task_data["title"], diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 36eb81f64..a81811c06 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -70,6 +70,9 @@ def add_tasks_to_goal(goal_id): task_ids = request_body.get("task_ids", []) + for task in goal.tasks: + task.goal_id = None + for task_id in task_ids: # add helper function here task = validate_model(Task, task_id) task.goal_id = goal.id diff --git a/tests/test_wave_06.py b/tests/test_wave_06.py index dec4e8acd..097c7cfd6 100644 --- a/tests/test_wave_06.py +++ b/tests/test_wave_06.py @@ -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={ From d3ebd6eecf09fd8871bc2fb3250fcf3db25fce03 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Thu, 8 May 2025 23:15:35 +0200 Subject: [PATCH 21/24] passed all tests! --- app/models/goal.py | 6 +++--- app/models/task.py | 3 +-- app/routes/goal_routes.py | 2 +- app/routes/task_routes.py | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/models/goal.py b/app/models/goal.py index c1dcb7f85..32368b8e9 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -9,7 +9,7 @@ class Goal(db.Model): title: Mapped[str] #= mapped_column(nullable=False) tasks: Mapped[list["Task"]] = relationship("Task", back_populates="goal") - def to_dict(self): + def to_dict(self, include_tasks=False): goal_dict = { "id": self.id, "title": self.title, @@ -17,8 +17,8 @@ def to_dict(self): } # if self.tasks: - if hasattr(self, "tasks") and self.tasks: - goal_dict["tasks"] = [task.to_dict() for task in self.tasks] + if include_tasks: + goal_dict["tasks"] = [task.to_dict(include_goal_id=True) for task in self.tasks] return goal_dict diff --git a/app/models/task.py b/app/models/task.py index 1a3722c12..a9cbe1c43 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -17,13 +17,12 @@ class Task(db.Model): def to_dict(self, include_goal_id=False): task_dict = { "id": self.id, - # "goal_id": self.goal_id, "title": self.title, "description": self.description, "is_complete": self.completed_at is not None } - if include_goal_id: + if include_goal_id and self.goal_id is not None: task_dict["goal_id"] = self.goal_id return task_dict diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index a81811c06..64fdb6695 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -86,4 +86,4 @@ def add_tasks_to_goal(goal_id): def get_tasks_of_goal(goal_id): goal = validate_model(Goal, goal_id) - return goal.to_dict(), 200 \ No newline at end of file + return goal.to_dict(include_tasks=True), 200 \ No newline at end of file diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index d53ed2afd..771ad252b 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -69,7 +69,7 @@ def get_all_tasks(): def get_one_task(task_id): task = validate_model(Task, task_id) - return {"task": task.to_dict()}, 200 + return {"task": task.to_dict(include_goal_id=True)}, 200 # update task @bp.put("/") From 7b16eee0e152b543b252c4ac2a2dbb23a1fcb946 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Thu, 8 May 2025 23:32:07 +0200 Subject: [PATCH 22/24] refactoring --- app/__init__.py | 3 +-- app/models/goal.py | 4 +--- app/routes/goal_routes.py | 4 ++-- app/routes/route_utilities.py | 2 -- app/routes/task_routes.py | 7 ------- 5 files changed, 4 insertions(+), 16 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 6e447bbf7..d38f754bf 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -12,8 +12,7 @@ def create_app(config=None): app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('SQLALCHEMY_DATABASE_URI') if config: - # Merge `config` into the app's configuration - # to override the app's default settings for testing + app.config.update(config) db.init_app(app) diff --git a/app/models/goal.py b/app/models/goal.py index 32368b8e9..251a393fd 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -6,17 +6,15 @@ class Goal(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - title: Mapped[str] #= mapped_column(nullable=False) + title: Mapped[str] tasks: Mapped[list["Task"]] = relationship("Task", back_populates="goal") def to_dict(self, include_tasks=False): goal_dict = { "id": self.id, "title": self.title, - # "tasks": self.tasks } - # if self.tasks: if include_tasks: goal_dict["tasks"] = [task.to_dict(include_goal_id=True) for task in self.tasks] diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 64fdb6695..9acc3e520 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -62,7 +62,7 @@ def delete_goal(goal_id): return Response(status=204, mimetype="application/json") -# send a list of tasks to a goal +# add list of tasks to a goal @bp.post("//tasks") def add_tasks_to_goal(goal_id): goal = validate_model(Goal, goal_id) @@ -73,7 +73,7 @@ def add_tasks_to_goal(goal_id): for task in goal.tasks: task.goal_id = None - for task_id in task_ids: # add helper function here + for task_id in task_ids: task = validate_model(Task, task_id) task.goal_id = goal.id diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 15c3e1bf8..993514c90 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -17,11 +17,9 @@ def validate_model(cls, model_id): return model -# review this function and use for post route def create_model_from_dict(cls, data): model = cls.from_dict(data) db.session.add(model) db.session.commit() return model -# what about get model by filter? \ No newline at end of file diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 771ad252b..ab3bc6073 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -43,7 +43,6 @@ def get_all_tasks(): return tasks_response - #if you want to search by title or description: # if title_param: # query = query.where(Task.title.ilike(f"%{title_param}%")) @@ -120,12 +119,6 @@ def mark_task_complete(task_id): # sends request to slack slack_response = requests.post("https://slack.com/api/chat.postMessage", json=slack_message, headers=headers) - # #optional error handling - # if not slack_response.ok: - # print("Slack API error": slack_response.status_code, slack_response.text) - - # can refactor here into a helper function for the slack message - return Response(status=204, mimetype="application/json") @bp.patch("//mark_incomplete") From 950f2b8c511c5f2c80db8d2893bd4543d7b3a414 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Thu, 8 May 2025 23:34:29 +0200 Subject: [PATCH 23/24] cleans up code --- app/routes/task_routes.py | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index ab3bc6073..9d2b43d94 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -10,7 +10,7 @@ bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks") -# create a new task ... refactor +# create a new task @bp.post("") def create_task(): request_body = request.get_json() @@ -27,7 +27,6 @@ def create_task(): # read all tasks @bp.get("") def get_all_tasks(): - # create a basic select query without any filtering query = db.select(Task) sort = request.args.get("sort") @@ -41,27 +40,7 @@ def get_all_tasks(): tasks_response = [task.to_dict() for task in tasks] - return tasks_response - - #if you want to search by title or description: - # if title_param: - # query = query.where(Task.title.ilike(f"%{title_param}%")) - - # tasks = db.session.scalars(query.order_by(Task.title)) - # # # We could also write the line above as: - # # # books = db.session.execute(query).scalars() - - # # If we have other query parameters, we can continue adding to the query. - # # As we did above, we must reassign the `query`` variable to capture the new clause we are adding. - # # If we don't reassign `query``, we are calling the `where` function but are not saving the resulting filter - # description_param = request.args.get("description") - # if description_param: - # # In case there are books with similar titles, we can also filter by description - # query = query.where(Book.description.ilike(f"%{description_param}%")) - - # tasks = db.session.scalars(query.order_by(Task.id)) - - + return tasks_response # read one task @bp.get("/") From 059efbdba33bcd28d6e0e97ce9c2a86b9677a9f1 Mon Sep 17 00:00:00 2001 From: Malik Elmessiry Date: Fri, 9 May 2025 13:21:17 +0200 Subject: [PATCH 24/24] cleans up code --- app/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/__init__.py b/app/__init__.py index d38f754bf..d2cf87ed4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,6 +3,8 @@ from .models import task, goal from .routes.task_routes import bp as tasks_bp from .routes.goal_routes import bp as goals_bp +from dotenv import load_dotenv +load_dotenv() import os def create_app(config=None):