Skip to content
5 changes: 5 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 .routes.task_routes import bp as tasks_bp
from .routes.goal_routes import bp as goals_bp
import os


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

Expand All @@ -18,5 +21,7 @@ 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 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):
goal_as_dict = {
"id": self.id,
"title": self.title
}

return goal_as_dict

@classmethod
def from_dict(cls, task_data):

Choose a reason for hiding this comment

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

There's always a risk of forgetting to rename things if we copy/paste from a similar file. If we know we're likely to copy from a file, a more neutral name can help avoid cases like this.

new_goal = cls(title=task_data["title"])

return new_goal
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 sqlalchemy import ForeignKey
from datetime import datetime
from ..db import db
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .goal import Goal
Comment on lines +6 to +7

Choose a reason for hiding this comment

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

👍 Nice use of this snippet to suppress the squiggles in VS Code.



class Task(db.Model):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str]
description: Mapped[str]
completed_at: Mapped[datetime] = mapped_column(nullable=True)

Choose a reason for hiding this comment

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

Setting nullable=true is equivalent to marking the Mapped type Optional (as you did elsewhere). While either works, the Optional approach is preferred since it also provides information to the type checking warnings in VS Code.

Generally, if there are multiple ways to achieve the same outcome, we should be consistent in our approaches, as encountering different approaches requires the reader to stop and think through whether there is a particular reason why the different approaches are used.

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

def to_dict(self):
task_as_dict = {
"id": self.id,
"title": self.title,
"description": self.description,
"is_complete": True if self.completed_at else False,

Choose a reason for hiding this comment

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

Even though we only use the calculated is_complete value when converting to a result dict, and even though it's a single line, it can be useful to move this logic to a well-named helper. This would make the dict building here more self-documenting, and provides a clear name to the operation being performed.

And even though this line is concise, notice that it's still effectively of the form

if some_condition:  # (some condition is either truthy or falsy)
    return True  # (returns True if some_condition is truthy)
else:
    return False  # (returns False if some_condition is falsy)

This is an antipattern, which we should try to avoid. Rather than checking the condition and using that to pick between True and False, we can use the condition value directly as long as we force it to a boolean value, such as

bool(some_condition)  # (None would become False, and a completed date would be True)

or if we prefer to be more explicit

some_condition is not None  # (same behavior)

}
Comment on lines +19 to +24

Choose a reason for hiding this comment

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

👍 Not including the outer task "envelope" wrapper in our to_dict keeps it more general-purpose. We can use it in endpoints that require the envelope by embedding this result in an outer dictionary, or use in other cases where the wrapper is not called for in the specifications.


if self.goal_id:
task_as_dict["goal_id"] = self.goal_id
Comment on lines +26 to +27

Choose a reason for hiding this comment

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

👍 Nice logic to add the goal data to the task dictionary only when it's present.


return task_as_dict

@classmethod
def from_dict(cls, task_data):
new_task = cls(
title=task_data["title"],
description=task_data["description"])
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.

👍 By reading title and description directly as keys we can trigger a KeyError if they are absent, giving us a way to indicate they are required.

Choose a reason for hiding this comment

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

Though none of our tests attempt to pass completed_at, this is something we should still consider handling. The main reason we didn't provide an example is because we couldn't be certain how folks would represent the completed at (string or datetime) nor could we be sure what datetime format they would be expecting (there's no official standard for passing datetime data in JSON). This is also why we simplified the Task response representation to use is_complete rather than returning the actual datetime information.

However, once we have settled on our own method of representing completed_at, we can also allow the caller to pass appropriately formatted data, reading it from the data dictionary in a way that would let it remain optional


return new_task
80 changes: 79 additions & 1 deletion app/routes/goal_routes.py

Choose a reason for hiding this comment

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

👍 Your Goal CRUD routes are consistent with your Task CRUD routes. Check the Task feedback for anything that could apply here.

Original file line number Diff line number Diff line change
@@ -1 +1,79 @@
from flask import Blueprint
from flask import Blueprint, request, Response
from app.models.goal import Goal
from app.models.task import Task
from .route_utilities import validate_model, create_model
from .route_utilities import get_models_with_filters
from ..db import db


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


@bp.post("")
def create_goal():
request_body = request.get_json()
return create_model(Goal, request_body)


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


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

return {"goal": 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 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")


@bp.post("/<goal_id>/tasks")
def add_tasks_to_goal(goal_id):
goal = validate_model(Goal, goal_id)
request_body = request.get_json()

for task in goal.tasks:
task.goal_id = None

tasks = [validate_model(Task, id) for id in request_body["task_ids"]]

Choose a reason for hiding this comment

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

👍 Nice way to validate each of the task ids, getting an actual Task model.


for task in tasks:
task.goal_id = goal_id
Comment on lines +60 to +61

Choose a reason for hiding this comment

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

We could make use of the tasks relationship to allow us to replace all of the associated tasks in one fell swoop. Once we have a list of Task models, we can assign that list directly to the relationship.

    goal.tasks = tasks


db.session.commit()

response = {
"id": goal.id,
"task_ids": request_body["task_ids"]
}

return response, 200


@bp.get("/<goal_id>/tasks")
def get_all_tasks_of_goal(goal_id):
goal = validate_model(Goal, goal_id)
response = goal.to_dict()
response["tasks"] = [task.to_dict() for task in goal.tasks]
Comment on lines +76 to +77

Choose a reason for hiding this comment

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

👍 Nice reuse of your to_dict methods to build up your response. Rather than leaving this logic here in the route, we could move it to an additional Goal method. Perhaps to_dict_with_tasks, or we could add a parameter to the main Goal to_dict that determines whether or not to include the Task details.


return response, 200
55 changes: 55 additions & 0 deletions app/routes/route_utilities.py

Choose a reason for hiding this comment

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

👍 Nice use of the main 3 route helpers introduced in the curriculum. This isn't the only way to address the common behaviors across the model routes. There are details we didn't explore. These could be organized in other ways. There are even other routes that could benefit from similar helpers (such as PUT and DELETE). We should keep an eye out for additional opportunities to DRY our code and separate responsibilities as we continue to work with larger codebases.

Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from flask import abort, make_response
from ..db import db


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

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

if not model:
response = {"message": f"{cls.__name__} {model_id} not found"}
abort(make_response(response, 404))

return model


def create_model(cls, model_data):
try:
new_model = cls.from_dict(model_data)

except KeyError:
response = {"details": "Invalid data"}
abort(make_response(response, 400))

db.session.add(new_model)
db.session.commit()

return {cls.__name__.lower(): new_model.to_dict()}, 201

Choose a reason for hiding this comment

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

👍 Nice customization of create_model to account for the outer wrapper "envelope" in a way that works for either model type.



def get_models_with_filters(cls, filters=None):
query = db.select(cls)
sort = ""

if filters:
for attribute, value in filters.items():
if hasattr(cls, attribute):
query = query.where(getattr(cls, attribute).ilike(f"%{value}%"))
if attribute == "sort":
sort = value

if sort == "asc":
models = db.session.scalars(query.order_by(cls.title.asc()))

Choose a reason for hiding this comment

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

Nice use of order_by to perform the title sorting in the database itself. Databases are very good at ordering results while fetching them, freeing our logic from needing to perform the sort itself.

elif sort == "desc":
models = db.session.scalars(query.order_by(cls.title.desc()))
else:
models = db.session.scalars(query.order_by(cls.id))
Comment on lines +44 to +52

Choose a reason for hiding this comment

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

👍 Nice customization of get_models_with_filters to provide sorting functionality. How could we generalize this even further to sort on other attributes?


models_response = [model.to_dict() for model in models]
return models_response
86 changes: 85 additions & 1 deletion app/routes/task_routes.py

Choose a reason for hiding this comment

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

👍 Great use of the route helpers to simplify our task routes!

Original file line number Diff line number Diff line change
@@ -1 +1,85 @@
from flask import Blueprint
from flask import Blueprint, request, Response
from app.models.task import Task
from .route_utilities import validate_model, create_model
from .route_utilities import get_models_with_filters
from ..db import db
from datetime import datetime
import requests
import 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():
return get_models_with_filters(Task, request.args)


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

return {"task": task.to_dict()}


@bp.put("/<id>")
def update_task(id):

Choose a reason for hiding this comment

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

Notice how similar updating is to creating. After validating the record to update, we require certain keys to be present, others can be optional, then after updating and committing the model, we return a common response. How could we add model or route helpers to simplify our PUT route?

task = validate_model(Task, id)
request_body = request.get_json()

task.title = request_body["title"]
task.description = request_body["description"]
if "completed_at" in request_body:
task.completed_at = request_body["completed_at"]
db.session.commit()

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


@bp.delete("/<id>")
def delete_task(id):

Choose a reason for hiding this comment

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

Notice how similar deleting is to getting. After validating the record to delete, we delete and commit it, then return a common response. How could we add model or route helpers to simplify our DELETE route?

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 marks_task_incomplete(id):
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.

👍 Nice route to mark the task incomplete. We can use our validation helper to get the same behavior as the other id-based routes, leaving our route responsible only for clearing the completion date, saving it, and generating the response.

One thing we might still consider is moving the actual update logic into a model helper so that the Task model class itself is responsible for "knowing" how to mark a Task incomplete.

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 marks_task_complete(id):
Comment on lines +63 to +64

Choose a reason for hiding this comment

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

👍 Nice route to mark the task complete. We can use our validation helper to get the same behavior as the other id-based routes, leaving our route responsible only for updating the record with the current datetime, saving it, and generating the response.

One thing we might still consider is moving the actual update logic into a model helper so that the Task model class itself is responsible for "knowing" how to complete a Task.

task = validate_model(Task, id)
task.completed_at = datetime.now()
db.session.commit()

send_slack_notif(task)

Choose a reason for hiding this comment

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

👍 Nice use of a helper function to hold the logic for performing the notification.

Prefer to spell out the words in the name fully rather than abbreviating them (notification rather than notif).


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


def send_slack_notif(task):

Choose a reason for hiding this comment

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

This function encapsulates the responsibility of sending a completion notification about the provided task. Notice that we could make a further helper function that wraps the responsibility of sending a message to a specified channel. This function would then be responsible for the logic of building the messaging, and knowing what channel to use.

Even the logic to build the notification message based on the task could be in its own helper. Think about whether such a potential function would be a model method, or some other method to which we pass a Task.

data = {
"token": os.environ.get('SLACKBOT_TOKEN'),

Choose a reason for hiding this comment

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

Note that the Slack API documentation prefers passing the API key in the header rather than in the request body. Passing the token as part of the request body works here due to the use of the data= named parameter rather than json=. data= encodes the body as x-www-form-urlencoded rather than as JSON. Slack doesn't allow the token to be passed in a JSON body (hence needing to use data=), but if we pass the token through the preferred header, then we could send the request body as JSON.

"channel": os.environ.get('SLACK_CHANNEL'),
Comment on lines +76 to +77

Choose a reason for hiding this comment

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

👍 Using get results in a None value being assigned for the values missing from the .env (or using the explicit default value), which lets the code run without crashing during tests even if the expected keys aren't set. However, if we wanted to force that all the expected .env values are set, we could use direct key access.

"text": f"Someone just complete the task {task.title}"
}

response = requests.post(
"https://slack.com/api/chat.postMessage",

Choose a reason for hiding this comment

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

We could store the Slack API URL in the .env too. If for some reason, there was a change to the API endpoint, we could then update where the endpoint looked for it without needing to update the code itself.

data=data)

return 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.
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