Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.vscode
.DS_Store
.env

# Byte-compiled / optimized / DLL files
__pycache__/
Expand Down
Binary file added ada-project-docs/assets/lexy-slackbot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions ada-project-docs/wave_04.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,11 @@ Send `PATCH` requests to `localhost:5000/tasks/<task_id>/mark_complete` (use the
![](assets/postman_patch.png)

![](assets/slack_notification_feature.png)



The result show as below
![](assets/lexy-slackbot.png)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for including the image ^_^




6 changes: 6 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from flask import Flask
from .db import db, migrate
from .models import task, goal
from app.routes.task_routes import bp as tasks_bp
from app.routes.goal_routes import bp as goals_bp
import os


def create_app(config=None):
app = Flask(__name__)

Expand All @@ -18,5 +21,8 @@ def create_app(config=None):
migrate.init_app(app, db)

# Register Blueprints here
app.register_blueprint(tasks_bp)

app.register_blueprint(goals_bp)

return app
22 changes: 21 additions & 1 deletion app/models/goal.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..db import db
from sqlalchemy import String



class Goal(db.Model):

id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(100), nullable=False)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For many applications, it may make sense to add a limit to the title, however nothing in the project requirements asks for there to be a limit on the title length for the Goal class.

Especially when working with others, we want to avoid changing or adding behavior that has already been decided for a product, unless those changes have been discussed and signed off on by stakeholders for the project.

We are working on this in isolation, but in a project with other folks, the choice to have no title limit may have come from user research on how folks are using the product. Introducing a limit that other developers were not expecting could lead to issues for other developers features or customer complaints when things do not work as expected or advertised.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nullable=False is the default value for an attribute in SQLAlchemy. It is more common to leave off that section if we are not changing the default behavior. If we are removing the added length limit as well, then we could remove the mapped_column content entirely.

tasks: Mapped[list["Task"]] = relationship(
back_populates="goal")


def to_dict(self):
return {
"id": self.id,
"title": self.title
}

@classmethod
def from_dict(cls, data_dict):
if "title" not in data_dict:
raise KeyError("title")
Comment on lines +23 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pattern of checking for a key and raising a KeyError ourselves is an anti-pattern we should avoid.

The Python language will raise a KeyError for us if we try to access the title key, so we should simplify our code by relying on that.

  • The create goal route already has a try/except to catch the raised error, so here in this function, we can remove this if block and let the error raise on the next line if it does come up. We can rely on the route to handle the raised error and use abort to send a correctly formed error response.

return cls(title=data_dict["title"])
46 changes: 45 additions & 1 deletion app/models/task.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,49 @@
from sqlalchemy.orm import Mapped, mapped_column
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import Mapped, mapped_column,relationship
from sqlalchemy import ForeignKey
from ..db import db
from sqlalchemy import String, Date
from datetime import datetime
from flask import Blueprint, abort, make_response, request, Response
from typing import Optional
from datetime import date

class Task(db.Model):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str] = mapped_column(String(255))
completed_at:Mapped[date] = mapped_column(Date, nullable=True)

goal_id: Mapped[Optional[int]] = mapped_column(ForeignKey("goal.id"))
goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks")

def to_dict(self): # json


task_dict = {"id": self.id,
"title":self.title,
"description":self.description,
"is_complete": self.completed_at is not None}

if self.goal_id is not None:
task_dict["goal_id"] = self.goal_id


return task_dict

@classmethod
def from_dict(cls, data_dict):
if "title" not in data_dict:

raise KeyError("title")
if "description" not in data_dict:
raise KeyError("description")

return cls(
title=data_dict["title"],
description=data_dict["description"],
completed_at=None # default to None when creating
)



82 changes: 81 additions & 1 deletion app/routes/goal_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,81 @@
from flask import Blueprint
from flask import Blueprint, request, abort, make_response,Response
from ..db import db
from ..models.goal import Goal
from ..models.task import Task
from .route_utilities import validate_model,create_model,get_models_with_filters

bp = Blueprint("goals_bp", __name__, url_prefix="/goals")

@bp.post("")
def create_goal():
request_body = request.get_json()

return create_model(Goal,request_body)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice update to use create_model for the Goal class! Is there a reason we cannot use it for Tasks as well?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just updated


@bp.get("")
def get_all_goals():
filters = request.args.to_dict()
return get_models_with_filters(Goal,filters)

@bp.get("/<id>")
def get_one_goal(id):
goal = validate_model(Goal, id)
return goal.to_dict()

@bp.put("/<id>")
def update_goal(id):
goal = validate_model(Goal, id)
request_body = request.get_json()

goal.title = request_body["title"]

db.session.commit()
return goal.to_dict()

@bp.delete("/<id>")
def delete_goal(id):
goal = validate_model(Goal, id)

db.session.delete(goal)
db.session.commit()

return Response(status=204, mimetype="application/json")

#nested
@bp.post("/<goal_id>/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 in goal.tasks:
task.goal_id = None

for task_id in task_ids:
task = validate_model(Task, task_id)
task.goal_id = goal.id

db.session.commit()

return {
"id": goal.id,
"task_ids": task_ids
}, 200

@bp.get("/<goal_id>/tasks")
def get_tasks_for_goal(goal_id):
goal = validate_model(Goal, goal_id)

tasks_response = [task.to_dict() for task in goal.tasks]

response_body = {
"id": goal.id,
"title": goal.title,
"tasks": tasks_response
}

return response_body,200



41 changes: 41 additions & 0 deletions app/routes/route_utilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from flask import abort, make_response
from ..db import db

def validate_model(cls, id):
try:
id = int(id)
except ValueError:
invalid = {"message": f"{cls.__name__} id({id}) is invalid."}
abort(make_response(invalid, 400))

query = db.select(cls).where(cls.id == id)
model = db.session.scalar(query)

if not model:
not_found = {"message": f"{cls.__name__} with id({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:
abort(make_response({"details": "Invalid data"}, 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
113 changes: 112 additions & 1 deletion app/routes/task_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,112 @@
from flask import Blueprint
from ..models.task import Task
from flask import Blueprint, abort, make_response, request, Response,current_app
from ..db import db
from .route_utilities import validate_model,create_model,get_models_with_filters
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_models_with_filters is imported into this file but is never used.

from datetime import datetime,timezone
import requests,os

bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks")

@bp.post("")
def create_task():

request_body = request.get_json()
return create_model(Task,request_body)

@bp.get("")
def get_all_tasks():
query = db.select(Task)
id_param = request.args.get("id")

if id_param:
query = query.where(Task.id == int(id_param))

description_param = request.args.get("description")
if description_param:
query = query.where(Task.description.ilike(f"%{description_param}%"))

title_param = request.args.get("title")
sort_param = request.args.get("sort")
if title_param:
query = query.where(Task.title.ilike(f"%{title_param}%"))

complete_param = request.args.get("is_complete")
if complete_param == "true":

query = query.where(Task.completed_at.is_not(None))

elif complete_param == "false":

query = query.where(Task.completed_at.is_(None))

if sort_param =="asc":
query = query = query.order_by(Task.title.asc())
elif sort_param == "desc":
query = query.order_by(Task.title.desc())
else:
query = query.order_by(Task.id)

tasks = db.session.scalars(query)

result_list = []
result_list =[task.to_dict() for task in tasks]

return result_list or []

@bp.get("/<id>")
def get_one_task(id):

task =validate_model(Task,id)
return task.to_dict()

@bp.put("/<id>")
def replace_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")

Copy link
Contributor

@kelsey-steven-ada kelsey-steven-ada Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spacing between and inside of functions is inconsistent across the file. I would like to see this revisited if you are already working in this file.

This is something that will come up in code reviews in engineering teams, code style is important to readability. This is something we need to work into our review process for ourselves before we open pull requests.

@bp.delete("/<id>")
def del_task(id):
task = validate_model(Task, id)

db.session.delete(task)
db.session.commit()

return Response(status=204, mimetype="application/json")


@bp.patch("/<id>/mark_incomplete")
def mark_incomplete(id):
task = validate_model(Task, id)
task.completed_at = None

db.session.commit()

return Response(status=204, mimetype="application/json")

@bp.patch("/<id>/mark_complete")
def mark_task_complete(id):
task = validate_model(Task, id)
task.completed_at = datetime.now(timezone.utc)
db.session.commit()

slack_token = os.environ.get("SLACK_BOT_TOKEN")
slack_channel = os.environ.get("SLACK_CHANNEL", "#test_task_slack_api")
message = f"Task *{task.title}* has been completed!"

if not current_app.config.get("TESTING"):
response = requests.post(
"https://slack.com/api/chat.postMessage",
headers={"Authorization": f"Bearer {slack_token}"},

json={"channel": slack_channel, "text": message}
)

return Response(status=204, mimetype="application/json")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice restructuring so the message is only sent in production mode, but we always return the same response.


1 change: 1 addition & 0 deletions migrations/README
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Single-database configuration for Flask.
Loading