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
15 changes: 8 additions & 7 deletions ada-project-docs/wave_04.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from flask import Flask
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
Comment on lines +4 to +5

Choose a reason for hiding this comment

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

👍

import os

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

# Register Blueprints here
app.register_blueprint(task_bp)
app.register_blueprint(goal_bp)

return app
19 changes: 18 additions & 1 deletion app/models/goal.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
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,
"title":self.title
}

@classmethod
def from_dict(cls, goal_data):
return cls(
title=goal_data["title"]
)
34 changes: 33 additions & 1 deletion app/models/task.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
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 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)

Choose a reason for hiding this comment

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

Using Optional[] is all that we need to indicate that a field is nullable so we don't need to also include mapped_column with nullable=True (ilke how you did it for goal_id below on line 14).

Suggested change
completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
completed_at: Mapped[Optional[datetime]]

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

def to_dict(self):
task = {
"id":self.id,
"title":self.title,
"description":self.description,
"is_complete": self.is_complete()

Choose a reason for hiding this comment

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

Suggested change
"is_complete": self.is_complete()
"is_complete": True if self.completed_at else False

Instead of using a helper function, we could just use a ternary operator here.

}
if self.goal_id:
task["goal_id"] = self.goal_id
return task

def is_complete(self):
return self.completed_at is not None
Comment on lines +28 to +29

Choose a reason for hiding this comment

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

This logic is simple enough that you could get away with not using a helper function and using a ternary instead on line 22


@classmethod
def from_dict(cls, task_data):
return cls(
title=task_data["title"],
description=task_data["description"],
completed_at=task_data.get("completed_at")
)
76 changes: 75 additions & 1 deletion app/routes/goal_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,75 @@
from flask import Blueprint
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 ..models.task import Task
from ..db import db


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

Choose a reason for hiding this comment

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

Nitpick: spacing is off

Suggested change
bp=Blueprint("goal_bp", __name__, url_prefix="/goals")
bp = Blueprint("goal_bp", __name__, url_prefix="/goals")


@bp.get("")
def get_goals():
query=db.select(Goal)

Choose a reason for hiding this comment

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

Nitpick: spacing

Suggested change
query=db.select(Goal)
query = db.select(Goal)

goals = db.session.scalars(query.order_by(Goal.id))
goals_response=[]

Choose a reason for hiding this comment

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

Nitpick: spacing

Suggested change
goals_response=[]
goals_response = []

for goal in goals:
goals_response.append(goal.to_dict())
return goals_response
Comment on lines +16 to +19

Choose a reason for hiding this comment

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

This would be a good place for list comprehension to make the route more concise.

Suggested change
goals_response=[]
for goal in goals:
goals_response.append(goal.to_dict())
return goals_response
return [goal.to_dict for goal in goals]


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

Choose a reason for hiding this comment

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

Nitpick: missing space after comma

The default status code Flask returns to the client is 200. Therefore we do not need to return it ourselves explicitly.

Note that you don't explicitly return 200 for the get_goals and get_one_goal_tasks routes.

I'd prefer not to explicitly return a status code if I don't need to because it adds more code and feels redundant because the framework does it for us. If you choose to return 200 or choose not to, just be consistent in the choice and apply the decision throughout the entire codebase, instead of sometimes returning it and sometimes not.

Suggested change
return {"goal": goal.to_dict()},200
return {"goal": goal.to_dict()}


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

if goal.tasks:
tasks = [task.to_dict() for task in goal.tasks]
response_body["tasks"] = tasks
else:
response_body["tasks"] = []
Comment on lines +34 to +35

Choose a reason for hiding this comment

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

If a goal doesn't have tasks then the attribute tasks will already be an empty list therefore it's redundant to reassign it to an empty list.

Suggested change
else:
response_body["tasks"] = []


return response_body

@bp.post("")
def create_goal():
request_body = request.get_json()
response = create_model(Goal, request_body)
return {"goal": response[0]}, response[1]

@bp.post("/<goal_id>/tasks")
def create_tasks_with_goal_id(goal_id):

goal = validate_model(Goal,goal_id)
request_body = request.get_json()
tasks = request_body.get("task_ids",[])

Choose a reason for hiding this comment

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

Naming is a little unclear. We're actually getting a list of task ids, not a list of task instances.

Suggested change
tasks = request_body.get("task_ids",[])
task_ids = request_body.get("task_ids",[])


if goal.tasks:
goal.tasks.clear()
Comment on lines +52 to +53

Choose a reason for hiding this comment

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

Do we need to clear tasks from goal when you reassign the attribute on line 55?

Suggested change
if goal.tasks:
goal.tasks.clear()


goal.tasks = [validate_model(Task, task) for task in tasks]

Choose a reason for hiding this comment

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

Naming is a little unclear. task indicates that the variable references an instance of tasks when task is actually an id for a task.

Suggested change
goal.tasks = [validate_model(Task, task) for task in tasks]
goal.tasks = [validate_model(Task, task_id) for task_id in tasks]


db.session.commit()

return make_response({"id":goal.id, "task_ids":tasks},200)

Choose a reason for hiding this comment

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

Nitpick: missing white spaces

Choose a reason for hiding this comment

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

We shouldn't need to use the make_response function here.

Choose a reason for hiding this comment

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

tasks is a list of ids that we get from the client in the request body (see line 50). Rather than sending a response back to the client that echos the input task_ids the client provided to us, we should fetch the task ids directly from the goal (since we appended the new tasks to the existing goal).

Suggested change
return make_response({"id":goal.id, "task_ids":tasks},200)
return {"id":goal.id, "task_ids": [task.id for task in goal.tasks]}


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

Choose a reason for hiding this comment

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

Nitpick: missing whitespace

Suggested change
goal.title=request_body["title"]
goal.title = request_body["title"]

Choose a reason for hiding this comment

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

This line of code will throw an unhandled exception if a client sends a bad request body that doesn't have a key "title".

How could you update this route so that you could send back an error response with some details and an appropriate status code so that the client knows what they need to fix when they resend the request?

db.session.commit()
return Response(status=204, mimetype="application/json")

@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")

43 changes: 43 additions & 0 deletions app/routes/route_utilities.py
Original file line number Diff line number Diff line change
@@ -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 = {"details": "Invalid data"}
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
99 changes: 98 additions & 1 deletion app/routes/task_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,98 @@
from flask import Blueprint
from flask import Blueprint, request, Response, abort, make_response
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

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

Choose a reason for hiding this comment

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

Nitpick: spacing is off.

The assignment operator should have spaces on either side.

Here's the guidance the PEP8 style guide provides: https://peps.python.org/pep-0008/#whitespace-in-expressions-and-statements

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


@bp.post("")
def create_task():
request_body = request.get_json()
response = create_model(Task, request_body)
return {"task": response[0]}, response[1]

@bp.get("")
def get_tasks():
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:
valid_task = task.to_dict()
if not has_special_chars(valid_task["title"]):
tasks_response.append(valid_task)
Comment on lines +30 to +34

Choose a reason for hiding this comment

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

Would be nice to use list comprehension here.

return tasks_response

def has_special_chars(title):
special_chars = "#$%()*+,-./:;<=>?@[\]^_`{|}~"
for char in title:
if char in special_chars:
raise ValueError
return False
Comment on lines +37 to +42

Choose a reason for hiding this comment

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

Why do we need this logic?

How would a request with a request body that has key "title" save a record to the DB in the first place?

Your post request uses create_model, which will call a model's from_dict method. The from_dict method will throw an exception if the request body's keys do not match the keys in the function:

        return cls(
            title=task_data["title"],
            description=task_data["description"],
            completed_at=task_data.get("completed_at")
        )

If we can't ever create and save a record with special characters, I think we could skip this logic.


@bp.get("/<id>")
def get_one_task(id):
task = validate_model(Task, id)
return {"task": task.to_dict()},200

Choose a reason for hiding this comment

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

Suggested change
return {"task": task.to_dict()},200
return {"task": task.to_dict()}


@bp.put("/<id>")
def update_task(id):
task = validate_model(Task, id)
request_body = request.get_json()

task.title=request_body["title"]
task.description=request_body["description"]
Comment on lines +54 to +55

Choose a reason for hiding this comment

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

These two lines will throw unhandled exceptions if the request body does not have keys exactly as "title" or "description". How could you update the logic in this route so that, instead of throwing a server error, you could return a nice error response with an appropriate status code?


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)

data = {
"token": f"{os.environ.get('SLACK_API_TOKEN')}",

Choose a reason for hiding this comment

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

We need to pass in the authorization token as a header, not as a key value pair in the json we send with the request. You do this on line 71 so we should remove the unnecessary code here.

Suggested change
"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,
Comment on lines +66 to +69

Choose a reason for hiding this comment

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

Prefer not to have string literal hanging out in a function. We should use a constant variable to reference the URL and channel name.

We call a string literal that appears in code magic strings: "Magic Strings are literal string values that are used directly in code without a clear explanation...This makes it difficult to maintain and extend the code in the future."

Read more about why we should avoid magic strings here

SLACK_CHANNEL = "test-slack-api"
SLACK_URL = "https://slack.com/api/chat.postMessage"

headers={
"Authorization": f"Bearer {os.environ.get('SLACK_API_TOKEN')}"
})
Comment on lines +64 to +72

Choose a reason for hiding this comment

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

Prefer all this logic to live in a helper function, maybe like call_slack_api to make this route more single-responsibility and concise.


if not task.completed_at:
task.completed_at = datetime.now()

db.session.commit()
return Response(status=204, mimetype="application/json")

@bp.patch("/<id>/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("/<id>")
def delete_task(id):
task = validate_model(Task, id)
db.session.delete(task)
db.session.commit()
return Response(status=204, mimetype="application/json")



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.
50 changes: 50 additions & 0 deletions migrations/alembic.ini
Original file line number Diff line number Diff line change
@@ -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
Loading