From 34946b9abd9f33dc18b1196bc8cab1ae7cb69bf7 Mon Sep 17 00:00:00 2001 From: Laura Castro Date: Wed, 7 May 2025 09:16:22 -0400 Subject: [PATCH 01/14] adds migration folder --- app/models/task.py | 25 +++++++++ migrations/README | 1 + migrations/alembic.ini | 50 +++++++++++++++++ migrations/env.py | 113 ++++++++++++++++++++++++++++++++++++++ migrations/script.py.mako | 24 ++++++++ 5 files changed, 213 insertions(+) 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/models/task.py b/app/models/task.py index 5d99666a4..9d2c8690c 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1,5 +1,30 @@ from sqlalchemy.orm import Mapped, mapped_column from ..db import db +from datetime import datetime +from typing import Optional class Task(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + title: Mapped[str] + description: Mapped[str] + completed_at: Mapped[Optional[bool]] + +# # Helper functions +# # to_dict() +# def to_dict(self): +# return { +# "id":self.id, +# "title":self.title, +# "description":self.description, +# "completed_at": datetime if self.completed_at else None +# } + +# # @classmethod +# # from_dict() +# @classmethod +# def from_dict(cls, task_data): +# return cls( +# title=task_data["title"], +# description=task_data["description"], +# completed_at=task_data["completed_at"] +# ) 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"} From f481c659d551856080716cb2cfa7e65ab54e754a Mon Sep 17 00:00:00 2001 From: Laura Castro Date: Wed, 7 May 2025 15:13:30 -0400 Subject: [PATCH 02/14] migrates successfully --- app/__init__.py | 2 + app/models/task.py | 30 ++++++------- app/routes/route_utilities.py | 43 ++++++++++++++++++ app/routes/task_routes.py | 45 ++++++++++++++++++- .../abd18229f418_updates_task_model.py | 39 ++++++++++++++++ tests/test_wave_01.py | 3 +- 6 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 app/routes/route_utilities.py create mode 100644 migrations/versions/abd18229f418_updates_task_model.py diff --git a/app/__init__.py b/app/__init__.py index 3c581ceeb..8ba6ea6db 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 task_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(task_bp) return app diff --git a/app/models/task.py b/app/models/task.py index 9d2c8690c..0e4b0a896 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1,30 +1,30 @@ from sqlalchemy.orm import Mapped, mapped_column from ..db import db -from datetime import datetime +# from datetime import datetime from typing import Optional class Task(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] description: Mapped[str] - completed_at: Mapped[Optional[bool]] + completed_at: Mapped[Optional[bool]] = mapped_column(nullable=True) # # Helper functions # # to_dict() -# def to_dict(self): -# return { -# "id":self.id, -# "title":self.title, -# "description":self.description, -# "completed_at": datetime if self.completed_at else None -# } + def to_dict(self): + return { + "id":self.id, + "title":self.title, + "description":self.description, + "completed_at": self.completed_at + } # # @classmethod # # from_dict() # @classmethod -# def from_dict(cls, task_data): -# return cls( -# title=task_data["title"], -# description=task_data["description"], -# completed_at=task_data["completed_at"] -# ) + def from_dict(cls, task_data): + return cls( + title=task_data["title"], + description=task_data["description"], + completed_at=task_data["completed_at"] + ) diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py new file mode 100644 index 000000000..c699904bd --- /dev/null +++ b/app/routes/route_utilities.py @@ -0,0 +1,43 @@ +from flask import abort, make_response +from ..db import db + +def validate_model(cls, model_id): + try: + model_id = int(model_id) + except ValueError: + invalid_response = {"message": f"{cls.__name__} id ({model_id}) is invalid."} + abort(make_response(invalid_response, 400)) + + query = db.select(cls).where(cls.id == model_id) + model = db.session.scalar(query) + + if not model: + not_found = {"message": f"{cls.__name__} with id ({model_id}) not found."} + abort(make_response(not_found, 404)) + + return model + +def create_model(cls, model_data): + try: + new_model = cls.from_dict(model_data) + except KeyError as e: + response = {"message": f"Invalid request: missing {e.args[0]}"} + abort(make_response(response, 400)) + + db.session.add(new_model) + db.session.commit() + + return 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, 200 \ No newline at end of file diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 3aae38d49..bd30c0a9e 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1 +1,44 @@ -from flask import Blueprint \ No newline at end of file +from flask import Blueprint, request, Response, abort, make_response +from .route_utilities import validate_model, create_model, get_models_with_filters +from ..models.task import Task +from ..db import db + +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_tasks(): +# return get_models_with_filters(Task, request.args) +# # When there are no saved task, this should return [] + +# @bp.get("/") +# def get_one_task(id): +# task = validate_model(Task, id) +# return task.to_dict(), 200 + +# @bp.put("/") +# def update_task(id): +# task = validate_model(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") + +# @bp.delete("/") +# def delete_task(id): +# task = validate_model(Task, id) +# db.session.delete(task) +# db.session.commit() +# return Response(status=204, mimetype="application/json") + +# @bp.get("?sort=asc") +# def get_asc_sorted_tasks(): + +# pass3 \ No newline at end of file diff --git a/migrations/versions/abd18229f418_updates_task_model.py b/migrations/versions/abd18229f418_updates_task_model.py new file mode 100644 index 000000000..fb47471ac --- /dev/null +++ b/migrations/versions/abd18229f418_updates_task_model.py @@ -0,0 +1,39 @@ +"""updates task model + +Revision ID: abd18229f418 +Revises: +Create Date: 2025-05-07 15:08:58.198553 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'abd18229f418' +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.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('task') + op.drop_table('goal') + # ### end Alembic commands ### diff --git a/tests/test_wave_01.py b/tests/test_wave_01.py index 55475db79..0cec83cbe 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") @@ -65,6 +65,7 @@ def test_get_task_not_found(client): # ***************************************************************** # **Complete test with assertion about response body*************** # ***************************************************************** + @pytest.mark.skip(reason="No way to test this feature yet") From 91ca025c05695bed92081def1d91810f55ff8916 Mon Sep 17 00:00:00 2001 From: Laura Castro Date: Thu, 8 May 2025 08:57:41 -0400 Subject: [PATCH 03/14] passes read tasks, one saved task, and one task --- app/models/task.py | 35 +++++++++++++++++++++++++---------- app/routes/task_routes.py | 23 ++++++++++++++++------- tests/conftest.py | 6 +++--- tests/test_wave_01.py | 9 ++++----- 4 files changed, 48 insertions(+), 25 deletions(-) diff --git a/app/models/task.py b/app/models/task.py index 0e4b0a896..ef5fda319 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -9,19 +9,34 @@ class Task(db.Model): description: Mapped[str] completed_at: Mapped[Optional[bool]] = mapped_column(nullable=True) -# # Helper functions -# # to_dict() def to_dict(self): return { - "id":self.id, - "title":self.title, - "description":self.description, - "completed_at": self.completed_at - } + "id":self.id, + "title":self.title, + "description":self.description, + "completed_at": self.completed_at + } + + # task_dict = { + # "id": self.id, + # "title": self.title, + # "description": self.description, + # "is_complete": self.completed_at, + # } + # return task_dict + + # def to_dict(self, include_completed_at=False): + # task_dict = { + # "id": self.id, + # "title": self.title, + # "description": self.description, + # "is_complete": bool(self.completed_at), + # } + # if include_completed_at: + # task_dict["completed_at"] = self.completed_at + # return task_dict -# # @classmethod -# # from_dict() -# @classmethod + @classmethod def from_dict(cls, task_data): return cls( title=task_data["title"], diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index bd30c0a9e..a3e72c928 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -10,15 +10,24 @@ def create_task(): request_body = request.get_json() return create_model(Task, request_body) -# @bp.get("") -# def get_tasks(): -# return get_models_with_filters(Task, request.args) -# # When there are no saved task, this should return [] - -# @bp.get("/") +@bp.get("") +def get_tasks(): + return get_models_with_filters(Task, request.args) + ''' + From the response body, get an array of tasks. If the length of the response it zero, return [] + Otherwise, convert tasks to dictionary + ''' +# When there are no saved task, this should return [] + +@bp.get("/") +def get_one_task(id): + task = validate_model(Task, id) + return {"task": task.to_dict()},200 + +# @task_bp.get("/") # def get_one_task(id): # task = validate_model(Task, id) -# return task.to_dict(), 200 +# return {"task": task.to_dict()} # @bp.put("/") # def update_task(id): diff --git a/tests/conftest.py b/tests/conftest.py index a01499583..b8ead3e38 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,10 +44,10 @@ def client(app): def one_task(app): new_task = Task(title="Go on my daily walk 🏞", description="Notice something new every day", - completed_at=None) + completed_at=False) db.session.add(new_task) db.session.commit() - + return new_task # This fixture gets called in every test that # references "three_tasks" @@ -67,7 +67,7 @@ def three_tasks(app): completed_at=None) ]) db.session.commit() - + # return array of 3 tasks? # This fixture gets called in every test that # references "completed_task" diff --git a/tests/test_wave_01.py b/tests/test_wave_01.py index 0cec83cbe..518fc65cb 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") @@ -28,12 +28,12 @@ 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 + "completed_at": False } ] -@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") @@ -47,11 +47,10 @@ def test_get_task(client, one_task): "id": 1, "title": "Go on my daily walk 🏞", "description": "Notice something new every day", - "is_complete": False + "completed_at": False } } - @pytest.mark.skip(reason="No way to test this feature yet") def test_get_task_not_found(client): # Act From 5f881dbafc38cc8ad8c92c8a557b8040fce988f8 Mon Sep 17 00:00:00 2001 From: Laura Castro Date: Thu, 8 May 2025 12:33:39 -0400 Subject: [PATCH 04/14] passes wave 1 and 2 --- app/models/task.py | 28 ++---- app/routes/route_utilities.py | 2 +- app/routes/task_routes.py | 94 ++++++++++++------- ...del.py => 376f2253ec12_adds_task_model.py} | 10 +- tests/conftest.py | 2 +- tests/test_wave_01.py | 41 +++----- tests/test_wave_02.py | 16 +++- tests/test_wave_03.py | 8 +- 8 files changed, 103 insertions(+), 98 deletions(-) rename migrations/versions/{abd18229f418_updates_task_model.py => 376f2253ec12_adds_task_model.py} (82%) diff --git a/app/models/task.py b/app/models/task.py index ef5fda319..ac7a08bfd 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1,45 +1,29 @@ from sqlalchemy.orm import Mapped, mapped_column from ..db import db -# from datetime import datetime +from datetime import datetime from typing import Optional class Task(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] description: Mapped[str] - completed_at: Mapped[Optional[bool]] = mapped_column(nullable=True) + completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) def to_dict(self): return { "id":self.id, "title":self.title, "description":self.description, - "completed_at": self.completed_at + "is_complete": self.is_complete() } - - # task_dict = { - # "id": self.id, - # "title": self.title, - # "description": self.description, - # "is_complete": self.completed_at, - # } - # return task_dict - # def to_dict(self, include_completed_at=False): - # task_dict = { - # "id": self.id, - # "title": self.title, - # "description": self.description, - # "is_complete": bool(self.completed_at), - # } - # if include_completed_at: - # task_dict["completed_at"] = self.completed_at - # return task_dict + def is_complete(self): + return 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["completed_at"] + completed_at=task_data.get("completed_at") ) diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index c699904bd..2d2d98e7f 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -21,7 +21,7 @@ def create_model(cls, model_data): try: new_model = cls.from_dict(model_data) except KeyError as e: - response = {"message": f"Invalid request: missing {e.args[0]}"} + response = {"details": "Invalid data"} abort(make_response(response, 400)) db.session.add(new_model) diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index a3e72c928..dad00b816 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1,5 +1,6 @@ from flask import Blueprint, request, Response, abort, make_response from .route_utilities import validate_model, create_model, get_models_with_filters +from datetime import datetime from ..models.task import Task from ..db import db @@ -8,46 +9,71 @@ @bp.post("") def create_task(): request_body = request.get_json() - return create_model(Task, request_body) + response = create_model(Task, request_body) + return {"task": response[0]}, response[1] @bp.get("") def get_tasks(): - return get_models_with_filters(Task, request.args) - ''' - From the response body, get an array of tasks. If the length of the response it zero, return [] - Otherwise, convert tasks to dictionary - ''' -# When there are no saved task, this should return [] + # If you have time, refactor this without utilitiy function + # return get_models_with_filters(Task, request.args) + query = db.select(Task) + + sort_param = request.args.get("sort") + + if sort_param == "asc": + tasks = db.session.scalars(query.order_by(Task.title.asc())) + elif sort_param == "desc": + tasks = db.session.scalars(query.order_by(Task.title.desc())) + else: + tasks = db.session.scalars(query.order_by(Task.id)) + + tasks_response = [] + for task in tasks: + tasks_response.append(task.to_dict()) + return tasks_response @bp.get("/") def get_one_task(id): task = validate_model(Task, id) return {"task": task.to_dict()},200 -# @task_bp.get("/") -# def get_one_task(id): -# task = validate_model(Task, id) -# return {"task": task.to_dict()} - -# @bp.put("/") -# def update_task(id): -# task = validate_model(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") - -# @bp.delete("/") -# def delete_task(id): -# task = validate_model(Task, id) -# db.session.delete(task) -# db.session.commit() -# return Response(status=204, mimetype="application/json") - -# @bp.get("?sort=asc") -# def get_asc_sorted_tasks(): - -# pass3 \ No newline at end of file +@bp.put("/") +def update_task(id): + task = validate_model(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") + +@bp.patch("//mark_complete") +def mark_task_complete(id): + task = validate_model(Task, id) + + if not task.completed_at: + task.completed_at = datetime.now() + + db.session.commit() + return Response(status=204, mimetype="application/json") + +@bp.patch("//mark_incomplete") +def mark_task_incomplete(id): + task = validate_model(Task, id) + + if task.completed_at: + task.completed_at = None + + db.session.commit() + return Response(status=204, mimetype="application/json") + +@bp.delete("/") +def delete_task(id): + task = validate_model(Task, id) + db.session.delete(task) + db.session.commit() + return Response(status=204, mimetype="application/json") + + + diff --git a/migrations/versions/abd18229f418_updates_task_model.py b/migrations/versions/376f2253ec12_adds_task_model.py similarity index 82% rename from migrations/versions/abd18229f418_updates_task_model.py rename to migrations/versions/376f2253ec12_adds_task_model.py index fb47471ac..c8c389fe7 100644 --- a/migrations/versions/abd18229f418_updates_task_model.py +++ b/migrations/versions/376f2253ec12_adds_task_model.py @@ -1,8 +1,8 @@ -"""updates task model +"""adds Task model -Revision ID: abd18229f418 +Revision ID: 376f2253ec12 Revises: -Create Date: 2025-05-07 15:08:58.198553 +Create Date: 2025-05-08 10:25:45.769864 """ from alembic import op @@ -10,7 +10,7 @@ # revision identifiers, used by Alembic. -revision = 'abd18229f418' +revision = '376f2253ec12' down_revision = None branch_labels = None depends_on = None @@ -26,7 +26,7 @@ def upgrade(): 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.Boolean(), nullable=True), + sa.Column('completed_at', sa.DateTime(), nullable=True), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### diff --git a/tests/conftest.py b/tests/conftest.py index b8ead3e38..e3f2817d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,7 +44,7 @@ def client(app): def one_task(app): new_task = Task(title="Go on my daily walk 🏞", description="Notice something new every day", - completed_at=False) + completed_at=None) db.session.add(new_task) db.session.commit() return new_task diff --git a/tests/test_wave_01.py b/tests/test_wave_01.py index 518fc65cb..d46ff9bc7 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", - "completed_at": False + "is_complete": False } ] @@ -47,11 +47,11 @@ def test_get_task(client, one_task): "id": 1, "title": "Go on my daily walk 🏞", "description": "Notice something new every day", - "completed_at": False + "is_complete": False } } -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_get_task_not_found(client): # Act response = client.get("/tasks/1") @@ -59,15 +59,10 @@ 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 with 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_task(client): # Act response = client.post("/tasks", json={ @@ -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={ @@ -116,8 +111,7 @@ def test_update_task(client, one_task): assert task.completed_at == None - -@pytest.mark.skip(reason="No way to test this feature yet") +# @pytest.mark.skip(reason="No way to test this feature yet") def test_update_task_not_found(client): # Act response = client.put("/tasks/1", json={ @@ -128,14 +122,10 @@ def test_update_task_not_found(client): # Assert assert response.status_code == 404 - - raise Exception("Complete test with assertion about response body") - # ***************************************************************** - # **Complete test with assertion about response body*************** - # ***************************************************************** + assert response_body == {"message": "Task with 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_task(client, one_task): # Act response = client.delete("/tasks/1") @@ -146,7 +136,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 +144,11 @@ 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 with id (1) 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 +165,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={ diff --git a/tests/test_wave_02.py b/tests/test_wave_02.py index a087e0909..233f2d02d 100644 --- a/tests/test_wave_02.py +++ b/tests/test_wave_02.py @@ -1,7 +1,6 @@ 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 +28,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") @@ -55,3 +54,14 @@ def test_get_tasks_sorted_desc(client, three_tasks): "is_complete": False, "title": "Answer forgotten email 📧"}, ] + +# When writing the tests below, ask yourself what you want the response to look like +@pytest.mark.skip(reason="Need to complete") +# Add a fixture that includes task with empty string, replace three_tasks +def test_task_no_title_empty_string(client, three_tasks): + pass + +@pytest.mark.skip(reason="Need to complete") +# Add a fixture that includes title with special char, replace three_tasks +def test_title_beginning_with_special_char(client, three_tasks): + pass diff --git a/tests/test_wave_03.py b/tests/test_wave_03.py index d7d441695..188894d48 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") From 4375e2b1c862a4a488734ca8c883e41a4211aeee Mon Sep 17 00:00:00 2001 From: Laura Castro Date: Thu, 8 May 2025 13:32:09 -0400 Subject: [PATCH 05/14] passes wave 3 --- tests/test_wave_03.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/tests/test_wave_03.py b/tests/test_wave_03.py index 188894d48..b2b08d17d 100644 --- a/tests/test_wave_03.py +++ b/tests/test_wave_03.py @@ -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,10 @@ def test_mark_complete_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 response_body == {"message": "Task with 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_mark_incomplete_missing_task(client): # Act response = client.patch("/tasks/1/mark_incomplete") @@ -109,8 +105,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 response_body == {"message": "Task with id (1) not found."} From 829f5943993ad67ee9c6de6cc4a9a6b413b92f4f Mon Sep 17 00:00:00 2001 From: Laura Castro Date: Thu, 8 May 2025 19:52:08 -0400 Subject: [PATCH 06/14] passes wave 4 and 5 --- ada-project-docs/wave_04.md | 15 +++--- app/__init__.py | 2 + app/models/goal.py | 13 +++++ app/routes/goal_routes.py | 45 ++++++++++++++++- app/routes/task_routes.py | 16 ++++-- migrations/versions/e4417e105134_.py | 32 ++++++++++++ tests/test_wave_05.py | 73 +++++++++++++--------------- 7 files changed, 145 insertions(+), 51 deletions(-) create mode 100644 migrations/versions/e4417e105134_.py diff --git a/ada-project-docs/wave_04.md b/ada-project-docs/wave_04.md index acfc78989..3a64e226d 100644 --- a/ada-project-docs/wave_04.md +++ b/ada-project-docs/wave_04.md @@ -99,10 +99,11 @@ Visit https://api.slack.com/methods/chat.postMessage to read about the Slack API Answer the following questions. These questions will help you become familiar with the API, and make working with it easier. -- What is the responsibility of this endpoint? -- What is the URL and HTTP method for this endpoint? -- What are the _two_ _required_ arguments for this endpoint? -- How does this endpoint relate to the Slackbot API key (token) we just created? +- What is the responsibility of this endpoint? This endpoint sends a message to a channel +- What is the URL and HTTP method for this endpoint? POST +https://slack.com/api/chat.postMessage +- What are the _two_ _required_ arguments for this endpoint? token and channel +- How does this endpoint relate to the Slackbot API key (token) we just created? This is what we will need to pass into the headers of the request so that we can be authenticated and granted access within scope of the token. In this case, to be able to send a message. Now, visit https://api.slack.com/methods/chat.postMessage/test. @@ -121,9 +122,9 @@ Press the "Test Method" button! Scroll down to see the HTTP response. Answer the following questions: -- Did we get a success message? If so, did we see the message in our actual Slack workspace? -- Did we get an error message? If so, why? -- What is the shape of this JSON? Is it a JSON object or array? What keys and values are there? +- Did we get a success message? If so, did we see the message in our actual Slack workspace? The value of "ok" is true in the response that I received. I can see the message in #test-slack-api. +- Did we get an error message? If so, why? No +- What is the shape of this JSON? Is it a JSON object or array? What keys and values are there? I get a JSON object with four keys: ok, channel, ts, and message. The message and blocks keys have nested JSON objects. ### Verify with Postman diff --git a/app/__init__.py b/app/__init__.py index 8ba6ea6db..c97ff2dde 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 task_bp +from .routes.goal_routes import bp as goal_bp import os def create_app(config=None): @@ -20,5 +21,6 @@ def create_app(config=None): # Register Blueprints here app.register_blueprint(task_bp) + app.register_blueprint(goal_bp) return app diff --git a/app/models/goal.py b/app/models/goal.py index 44282656b..65a7a05f4 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -3,3 +3,16 @@ class Goal(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + title: Mapped[str] + + def to_dict(self): + return { + "id":self.id, + "title":self.title + } + + @classmethod + def from_dict(cls, goal_data): + return cls( + title=goal_data["title"] + ) \ No newline at end of file diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 3aae38d49..cd84a10d6 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -1 +1,44 @@ -from flask import Blueprint \ No newline at end of file +from flask import Blueprint, request, Response, abort, make_response +import requests +import os +from .route_utilities import validate_model, create_model +from ..models.goal import Goal +from ..db import db + + +bp=Blueprint("goal_bp", __name__, url_prefix="/goals") + +@bp.get("") +def get_goals(): + query=db.select(Goal) + goals = db.session.scalars(query.order_by(Goal.id)) + goals_response=[] + for goal in goals: + goals_response.append(goal.to_dict()) + return goals_response + +@bp.get("/") +def get_one_goals(id): + goal = validate_model(Goal, id) + return {"goal": goal.to_dict()},200 + +@bp.post("") +def create_goal(): + request_body = request.get_json() + response = create_model(Goal, request_body) + return {"goal": response[0]}, response[1] + +@bp.put("/") +def update_goal(id): + goal = validate_model(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_goal(id): + goal = validate_model(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/task_routes.py b/app/routes/task_routes.py index dad00b816..0d6e3b207 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -1,5 +1,7 @@ from flask import Blueprint, request, Response, abort, make_response -from .route_utilities import validate_model, create_model, get_models_with_filters +import requests +import os +from .route_utilities import validate_model, create_model from datetime import datetime from ..models.task import Task from ..db import db @@ -14,8 +16,6 @@ def create_task(): @bp.get("") def get_tasks(): - # If you have time, refactor this without utilitiy function - # return get_models_with_filters(Task, request.args) query = db.select(Task) sort_param = request.args.get("sort") @@ -52,6 +52,16 @@ def update_task(id): def mark_task_complete(id): task = validate_model(Task, id) + data = { + "token": f"{os.environ.get('SLACK_API_TOKEN')}", + "channel":"test-slack-api", + "text":"Someone just completed the task My Beautiful Task" + } + response = requests.post("https://slack.com/api/chat.postMessage", data=data, + headers={ + "Authorization": f"Bearer {os.environ.get('SLACK_API_TOKEN')}" + }) + if not task.completed_at: task.completed_at = datetime.now() diff --git a/migrations/versions/e4417e105134_.py b/migrations/versions/e4417e105134_.py new file mode 100644 index 000000000..8b2a88c6f --- /dev/null +++ b/migrations/versions/e4417e105134_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: e4417e105134 +Revises: 376f2253ec12 +Create Date: 2025-05-08 18:56:24.274855 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e4417e105134' +down_revision = '376f2253ec12' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('goal', schema=None) as batch_op: + batch_op.add_column(sa.Column('title', sa.String(), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('goal', schema=None) as batch_op: + batch_op.drop_column('title') + + # ### end Alembic commands ### diff --git a/tests/test_wave_05.py b/tests/test_wave_05.py index 222d10cf0..fbaae8ade 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,19 @@ 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 with 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(client): # Act response = client.post("/goals", json={ @@ -80,34 +79,35 @@ def test_create_goal(client): } -@pytest.mark.skip(reason="test to be completed by student") +# @pytest.mark.skip(reason="test to be completed by student") def test_update_goal(client, one_goal): - raise Exception("Complete test") # Act - # ---- Complete Act Here ---- + response = client.put("/goals/1", json={ + "title": "Updated Goal 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) + task = db.session.scalar(query) -@pytest.mark.skip(reason="test to be completed by student") + assert task.title == "Updated Goal Title" + +# @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 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 == {"message": "Goal with 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") @@ -121,28 +121,21 @@ 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 response_body == {"message": "Goal with id (1) not found."} -@pytest.mark.skip(reason="test to be completed by student") +# @pytest.mark.skip(reason="test to be completed by student") def test_delete_goal_not_found(client): - raise Exception("Complete test") - # Act - # ---- Complete Act Here ---- + response = client.delete("/goas/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 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 95722d8852408b3604877429f0522cf91257b9fc Mon Sep 17 00:00:00 2001 From: Laura Castro Date: Fri, 9 May 2025 12:00:53 -0400 Subject: [PATCH 07/14] passes wave 5 --- app/models/goal.py | 8 +++++-- app/models/task.py | 9 ++++++-- app/routes/goal_routes.py | 24 +++++++++++++++++++- migrations/versions/f7038f873033_.py | 34 ++++++++++++++++++++++++++++ tests/test_wave_06.py | 14 +++++------- 5 files changed, 76 insertions(+), 13 deletions(-) create mode 100644 migrations/versions/f7038f873033_.py diff --git a/app/models/goal.py b/app/models/goal.py index 65a7a05f4..9cd47197d 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -1,10 +1,14 @@ -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db import db +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from .task import Task 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): return { "id":self.id, diff --git a/app/models/task.py b/app/models/task.py index ac7a08bfd..30afb54e5 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1,13 +1,18 @@ -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db import db +from sqlalchemy import ForeignKey from datetime import datetime -from typing import Optional +from typing import TYPE_CHECKING, Optional +if TYPE_CHECKING: + from .goal import Goal class Task(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] description: Mapped[str] completed_at: Mapped[Optional[datetime]] = 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/app/routes/goal_routes.py b/app/routes/goal_routes.py index cd84a10d6..67962b9f8 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -3,6 +3,7 @@ import os from .route_utilities import validate_model, create_model from ..models.goal import Goal +from ..models.task import Task from ..db import db @@ -22,12 +23,32 @@ def get_one_goals(id): goal = validate_model(Goal, id) return {"goal": goal.to_dict()},200 +@bp.get("//tasks") +def get_one_goal_tasks(goal_id): + goal = validate_model(Goal,goal_id) + tasks = [task.to_dict() for task in goal.tasks] + return tasks + @bp.post("") def create_goal(): request_body = request.get_json() response = create_model(Goal, request_body) return {"goal": response[0]}, response[1] +@bp.post("//tasks") +def create_tasks_with_goal_id(goal_id): + goal = validate_model(Goal,goal_id) + request_body = request.get_json() + tasks = request_body["task_ids"] + + for task in tasks: + valid_task = validate_model(Task, task) + valid_task.goal_id = goal.id + + db.session.commit() + + return make_response({"id":goal.id, "task_ids":tasks},200) + @bp.put("/") def update_goal(id): goal = validate_model(Goal, id) @@ -41,4 +62,5 @@ def delete_goal(id): goal = validate_model(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/migrations/versions/f7038f873033_.py b/migrations/versions/f7038f873033_.py new file mode 100644 index 000000000..a7ac15594 --- /dev/null +++ b/migrations/versions/f7038f873033_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: f7038f873033 +Revises: e4417e105134 +Create Date: 2025-05-09 10:06:38.560096 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f7038f873033' +down_revision = 'e4417e105134' +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..104aaf9c6 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={ @@ -20,12 +20,11 @@ def test_post_task_ids_to_goal(client, one_goal, three_tasks): "task_ids": [1, 2, 3] } - # Check that Goal was updated in the db query = db.select(Goal).where(Goal.id == 1) 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={ @@ -53,11 +52,10 @@ 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 == { + "id": 1, + "task_ids": [2, 4] + } @pytest.mark.skip(reason="No way to test this feature yet") From 4824099e4fb577dc38f951e0b17b1983933798d0 Mon Sep 17 00:00:00 2001 From: Laura Castro Date: Fri, 9 May 2025 13:24:52 -0400 Subject: [PATCH 08/14] adds pseudocode to nest task dict --- app/models/task.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/models/task.py b/app/models/task.py index 30afb54e5..8047c7d5f 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -24,6 +24,10 @@ def to_dict(self): def is_complete(self): return self.completed_at is not None + + # Create a function that creates nested dictionary {"task":{...}} + def nest_dict(self): + pass @classmethod def from_dict(cls, task_data): From f70fdb3bbada56ccecd634fb356ac6a903ba32ea Mon Sep 17 00:00:00 2001 From: Laura Castro Date: Fri, 9 May 2025 13:31:26 -0400 Subject: [PATCH 09/14] declres helper function to post slack message --- app/routes/route_utilities.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 2d2d98e7f..7d3565305 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -40,4 +40,8 @@ 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, 200 \ No newline at end of file + return models_response, 200 + +# Abstract slack bot message +def post_slack_message(): + pass \ No newline at end of file From d2fdfaf54c119246ef1e9453f676039d95f3fe5a Mon Sep 17 00:00:00 2001 From: Laura Castro Date: Fri, 9 May 2025 14:28:58 -0400 Subject: [PATCH 10/14] clarifies post route that overwrites tasks list --- app/models/goal.py | 5 +++++ app/routes/goal_routes.py | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/models/goal.py b/app/models/goal.py index 9cd47197d..e666d56a4 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -5,6 +5,11 @@ from .task import Task class Goal(db.Model): + + # Add table dunder var and set to goal + # The difference between having this or not, the table name itself is give the table a name, and hainvg it here gives us more control of the table being called. It allows us to define the name of the db table as opposed to the name to be automatically generated! This happens implicitly because it's a table dunder + __tablename__ = "goal" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] tasks: Mapped[list["Task"]] = relationship(back_populates="goal") diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 67962b9f8..7b7d8a2a1 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -37,10 +37,12 @@ def create_goal(): @bp.post("//tasks") def create_tasks_with_goal_id(goal_id): + # This request should create a new connecting, it updates the list that already exists, getting rid of tasks already associated with the goal, there are two differnt list, this creates a new list + # This needs to overwrite the existing tasks list [1] should be replaced with [2,4] goal = validate_model(Goal,goal_id) request_body = request.get_json() tasks = request_body["task_ids"] - + for task in tasks: valid_task = validate_model(Task, task) valid_task.goal_id = goal.id From 066cf5cf37b7213a7b33fdcdb5ff0506636583da Mon Sep 17 00:00:00 2001 From: Laura Castro Date: Wed, 14 May 2025 23:02:54 -0400 Subject: [PATCH 11/14] adds goal model --- app/models/goal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/goal.py b/app/models/goal.py index e666d56a4..39f32d5b4 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -7,7 +7,7 @@ class Goal(db.Model): # Add table dunder var and set to goal - # The difference between having this or not, the table name itself is give the table a name, and hainvg it here gives us more control of the table being called. It allows us to define the name of the db table as opposed to the name to be automatically generated! This happens implicitly because it's a table dunder + # The difference between having this or not, the table name itself is give the table a name, and hainvg it here gives us more control of the table being called. It allows us to define the name of the db table as opposed to the name to be automatically generated! This happens implicitly because it's a table dunder, which is a class level attribute __tablename__ = "goal" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) From ff35734c6b29c177e2f5c2632d97e4d078ec8a52 Mon Sep 17 00:00:00 2001 From: Laura Castro Date: Thu, 15 May 2025 00:51:29 -0400 Subject: [PATCH 12/14] passes post to overwrite existing task ids --- app/routes/goal_routes.py | 16 ++++++++++++---- app/routes/route_utilities.py | 3 ++- tests/test_wave_06.py | 4 ++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 67962b9f8..3638c419c 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -26,8 +26,12 @@ def get_one_goals(id): @bp.get("//tasks") def get_one_goal_tasks(goal_id): goal = validate_model(Goal,goal_id) - tasks = [task.to_dict() for task in goal.tasks] - return tasks + response_body = goal.to_dict() + if "tasks" not in response_body: + response_body["tasks"]=[] + else: + response_body["tasks"] = [task.to_dict() for task in goal.tasks] + return response_body @bp.post("") def create_goal(): @@ -39,12 +43,16 @@ def create_goal(): def create_tasks_with_goal_id(goal_id): goal = validate_model(Goal,goal_id) request_body = request.get_json() - tasks = request_body["task_ids"] + tasks = request_body.get("task_ids",[]) + + # This is my work around to get tests to pass. My concern is that this is a hardcoded solution that may cause side effects. Although I want to overwrite any existing tasks connected to the goal with teh given id, does this remove the task from the database? Technically tasks refers to a relationship of fields stored in a list, I'm not sure that this is the best solution. + for task_id in goal.tasks: + goal.tasks.remove(task_id) for task in tasks: valid_task = validate_model(Task, task) valid_task.goal_id = goal.id - + db.session.commit() return make_response({"id":goal.id, "task_ids":tasks},200) diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 2d2d98e7f..95e555560 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -40,4 +40,5 @@ 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, 200 \ No newline at end of file + return models_response, 200 + diff --git a/tests/test_wave_06.py b/tests/test_wave_06.py index 104aaf9c6..426f441a3 100644 --- a/tests/test_wave_06.py +++ b/tests/test_wave_06.py @@ -44,7 +44,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") @@ -58,7 +58,7 @@ def test_get_tasks_for_specific_goal_no_goal(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_get_tasks_for_specific_goal_no_tasks(client, one_goal): # Act response = client.get("/goals/1/tasks") From 3446d8069464dc2f62aa3d6f29708e2ba70e0802 Mon Sep 17 00:00:00 2001 From: Laura Castro Date: Fri, 16 May 2025 11:30:36 -0400 Subject: [PATCH 13/14] passes wave 6 --- app/models/goal.py | 5 ----- app/models/task.py | 9 ++++----- app/routes/goal_routes.py | 30 ++++++++++++------------------ app/routes/route_utilities.py | 7 ------- tests/test_wave_06.py | 12 ++++++++---- 5 files changed, 24 insertions(+), 39 deletions(-) diff --git a/app/models/goal.py b/app/models/goal.py index 39f32d5b4..9cd47197d 100644 --- a/app/models/goal.py +++ b/app/models/goal.py @@ -5,11 +5,6 @@ from .task import Task class Goal(db.Model): - - # Add table dunder var and set to goal - # The difference between having this or not, the table name itself is give the table a name, and hainvg it here gives us more control of the table being called. It allows us to define the name of the db table as opposed to the name to be automatically generated! This happens implicitly because it's a table dunder, which is a class level attribute - __tablename__ = "goal" - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] tasks: Mapped[list["Task"]] = relationship(back_populates="goal") diff --git a/app/models/task.py b/app/models/task.py index 8047c7d5f..15ae643ed 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -15,20 +15,19 @@ class Task(db.Model): goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") def to_dict(self): - return { + task = { "id":self.id, "title":self.title, "description":self.description, "is_complete": self.is_complete() } + if self.goal_id: + task["goal_id"] = self.goal_id + return task def is_complete(self): return self.completed_at is not None - # Create a function that creates nested dictionary {"task":{...}} - def nest_dict(self): - pass - @classmethod def from_dict(cls, task_data): return cls( diff --git a/app/routes/goal_routes.py b/app/routes/goal_routes.py index 699a32bd7..8ba5ffde7 100644 --- a/app/routes/goal_routes.py +++ b/app/routes/goal_routes.py @@ -27,11 +27,14 @@ def get_one_goals(id): def get_one_goal_tasks(goal_id): goal = validate_model(Goal,goal_id) response_body = goal.to_dict() - if "tasks" not in response_body: - response_body["tasks"]=[] + + if goal.tasks: + tasks = [task.to_dict() for task in goal.tasks] + response_body["tasks"] = tasks else: - response_body["tasks"] = [task.to_dict() for task in goal.tasks] - return response_body + response_body["tasks"] = [] + + return response_body @bp.post("") def create_goal(): @@ -41,24 +44,15 @@ def create_goal(): @bp.post("//tasks") def create_tasks_with_goal_id(goal_id): - # This request should create a new connecting, it updates the list that already exists, getting rid of tasks already associated with the goal, there are two differnt list, this creates a new list - # This needs to overwrite the existing tasks list [1] should be replaced with [2,4] + goal = validate_model(Goal,goal_id) request_body = request.get_json() -<<<<<<< HEAD tasks = request_body.get("task_ids",[]) - # This is my work around to get tests to pass. My concern is that this is a hardcoded solution that may cause side effects. Although I want to overwrite any existing tasks connected to the goal with teh given id, does this remove the task from the database? Technically tasks refers to a relationship of fields stored in a list, I'm not sure that this is the best solution. - for task_id in goal.tasks: - goal.tasks.remove(task_id) - -======= - tasks = request_body["task_ids"] - ->>>>>>> 2fee59f1182138868b55cc70d124397f1b102bac - for task in tasks: - valid_task = validate_model(Task, task) - valid_task.goal_id = goal.id + if goal.tasks: + goal.tasks.clear() + + goal.tasks = [validate_model(Task, task) for task in tasks] db.session.commit() diff --git a/app/routes/route_utilities.py b/app/routes/route_utilities.py index 46c1f97d8..e8b7ff004 100644 --- a/app/routes/route_utilities.py +++ b/app/routes/route_utilities.py @@ -41,10 +41,3 @@ def get_models_with_filters(cls, filters=None): models_response = [model.to_dict() for model in models] return models_response, 200 - -<<<<<<< HEAD -======= -# Abstract slack bot message -def post_slack_message(): - pass ->>>>>>> 2fee59f1182138868b55cc70d124397f1b102bac diff --git a/tests/test_wave_06.py b/tests/test_wave_06.py index 426f441a3..23cbcca6a 100644 --- a/tests/test_wave_06.py +++ b/tests/test_wave_06.py @@ -52,10 +52,14 @@ def test_get_tasks_for_specific_goal_no_goal(client): # Assert assert response.status_code == 404 - assert response_body == { + ''' + I disagree with this assertion. How can I return a response_body if the goal is not found? I made the assertion that the response body should equal the output message when the model is not found + assert response_body == { "id": 1, "task_ids": [2, 4] } + ''' + assert response_body == {'message': 'Goal with id (1) not found.'} # @pytest.mark.skip(reason="No way to test this feature yet") @@ -75,7 +79,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") @@ -94,13 +98,13 @@ 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 } ] } -@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 e4754291fd6e13cdb969a0fc9792833ec936ed72 Mon Sep 17 00:00:00 2001 From: Laura Castro Date: Sun, 18 May 2025 09:41:14 -0400 Subject: [PATCH 14/14] added wave 2 tests --- app/routes/task_routes.py | 11 ++++++++++- tests/conftest.py | 33 ++++++++++++++++++++++++++++++++- tests/test_wave_02.py | 39 +++++++++++++++++++++++++++++++++------ 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/app/routes/task_routes.py b/app/routes/task_routes.py index 0d6e3b207..ce4797c30 100644 --- a/app/routes/task_routes.py +++ b/app/routes/task_routes.py @@ -29,9 +29,18 @@ def get_tasks(): tasks_response = [] for task in tasks: - tasks_response.append(task.to_dict()) + valid_task = task.to_dict() + if not has_special_chars(valid_task["title"]): + tasks_response.append(valid_task) return tasks_response +def has_special_chars(title): + special_chars = "#$%()*+,-./:;<=>?@[\]^_`{|}~" + for char in title: + if char in special_chars: + raise ValueError + return False + @bp.get("/") def get_one_task(id): task = validate_model(Task, id) diff --git a/tests/conftest.py b/tests/conftest.py index e3f2817d7..bc69bf072 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -104,4 +104,35 @@ 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() + +@pytest.fixture +def no_title_task(app): + db.session.add_all([ + Task(title="Water the garden 🌷", + description="", + completed_at=None), + Task(title="", + description="", + completed_at=None), + Task(title="Pay my outstanding tickets 😭", + description="", + completed_at=None) + ]) + db.session.commit() + +@pytest.fixture +def title_starts_special_char(app): + db.session.add_all([ + Task(title="$ater the garden 🌷", + description="Plants seeds near companions.", + completed_at=None), + Task(title="Walk the dog", + description="Go around the park.", + completed_at=None), + Task(title="Pay my outstanding tickets 😭", + description="Make sure that the deadline hasn't passed.", + completed_at=None) + ]) + db.session.commit() + \ No newline at end of file diff --git a/tests/test_wave_02.py b/tests/test_wave_02.py index 233f2d02d..86f6aeb96 100644 --- a/tests/test_wave_02.py +++ b/tests/test_wave_02.py @@ -55,13 +55,40 @@ def test_get_tasks_sorted_desc(client, three_tasks): "title": "Answer forgotten email 📧"}, ] -# When writing the tests below, ask yourself what you want the response to look like -@pytest.mark.skip(reason="Need to complete") +# @pytest.mark.skip(reason="Need to complete") # Add a fixture that includes task with empty string, replace three_tasks -def test_task_no_title_empty_string(client, three_tasks): - pass +def test_task_no_title_empty_string(client, no_title_task): + # Act + response = client.get("/tasks?sort=desc") + response_body = response.get_json() -@pytest.mark.skip(reason="Need to complete") + # Assert + assert response.status_code == 200 + assert len(response_body) == 3 + assert response_body == [ + { + "description": "", + "id": 1, + "is_complete": False, + "title": "Water the garden 🌷"}, + { + "description": "", + "id": 3, + "is_complete": False, + "title": "Pay my outstanding tickets 😭"}, + { + "description": "", + "id": 2, + "is_complete": False, + "title": ""}, + ] + +# @pytest.mark.skip(reason="Need to complete") # Add a fixture that includes title with special char, replace three_tasks -def test_title_beginning_with_special_char(client, three_tasks): +def test_title_beginning_with_special_char(client, title_starts_special_char): + with pytest.raises(ValueError): + response = client.get("/tasks") + response_body = response.get_json() + +def test_title_exceeds_char_limit(client): pass