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
7 changes: 7 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
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
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.

Nice! You are following Flask convention to by naming your Blueprints bp and then using as to import them under an alias.

from dotenv import load_dotenv
import os

load_dotenv()

Choose a reason for hiding this comment

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

Using load_dotenv to ensure that your environmental variables are loaded before access. 👍🏿


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

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

# Register Blueprints here
app.register_blueprint(tasks_bp)
app.register_blueprint(goals_bp)
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.


return app
26 changes: 25 additions & 1 deletion app/models/goal.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
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]

Choose a reason for hiding this comment

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

Great work using the declarative mapping here! Since we are doing any specific declarations like in id we can simply use Mapped in conjunction with a Python datatype to declare what this column will look like.

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

Choose a reason for hiding this comment

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

Perfect! You are making a relationship attribute on the Goal model. This attribute is going to be a list of Task models. You then use relationship with back_populates to tell SQLAlchemy to sync this attribute with relationship attribute called goal on the Task model.


def to_dict(self, with_tasks=False):
goal_dict = {
"goal": {
"id": self.id,
"title": self.title
}
}

if with_tasks:
goal_dict["tasks"] = [task.to_dict()["task"] for task in self.tasks]

return goal_dict
Comment on lines +12 to +23

Choose a reason for hiding this comment

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

👍🏿


@classmethod
def from_dict(cls, goal_data):
return cls(
title=goal_data["title"]
)
42 changes: 41 additions & 1 deletion app/models/task.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,45 @@
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import ForeignKey
from datetime import datetime
from typing import Optional
from ..db import db
from typing import TYPE_CHECKING
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[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.

Instead of using nullable=True in the mapped_column declaration, we should use Optional in the type annotation to indicate that completed_at can be None. With that inclusion of Optional, it will actually be enough for SQLAlchemy to determine that this column should be nullable. The change would look like the following:

 completed_at: Mapped[Optional[datetime]]

Since you have Optional here we can remove the nullable=True from mapped_column.

"Nullability derives from whether or not the Optional[] type modifier is used." - SQLAlchemy Documentation

goal_id: Mapped[Optional[int]] = mapped_column(ForeignKey("goal.id"))
goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks")
Comment on lines +15 to +16

Choose a reason for hiding this comment

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

Nice work on the ForeignKey and establishing the other side of the ORM level relationship between our two models. ⭐️



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

if self.goal_id:
task_dict["task"]["goal_id"]= self.goal_id

return task_dict
Comment on lines +19 to +32

Choose a reason for hiding this comment

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

👍🏿


def is_complete(self):
return bool(self.completed_at)


@classmethod
def from_dict(cls, task_data):
return cls(
goal_id=task_data.get("goal_id"),

Choose a reason for hiding this comment

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

Great use of .get!

title=task_data["title"],
description=task_data["description"],
completed_at=task_data["completed_at"]
)
Empty file added app/routes/__init__.py
Empty file.
81 changes: 80 additions & 1 deletion app/routes/goal_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,80 @@
from flask import Blueprint
from flask import Blueprint, Response, request, jsonify
import requests
from app.models.goal import Goal
from .route_utilities import validate_model, create_model, get_models_with_filters, get_validated_tasks_from_request_body
from ..db import db

bp = Blueprint("goals_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.

⭐️



@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.to_dict()
Comment on lines +10 to +22

Choose a reason for hiding this comment

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

Very concise route functions! Don't forget we can use whitespace to distinguish different logic blocks in our functions.

Suggested change
@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.to_dict()
@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.to_dict()



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

for attribute, value in request_body.items():
if hasattr(Goal, attribute):
setattr(goal, attribute, value)

db.session.commit()
return Response(status=204, mimetype="application/json")
Comment on lines +30 to +35

Choose a reason for hiding this comment

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

Love this logic! Notice how you have essentially the same lines in your update task route as well. This logic has higher level usability and is used across multiple files. You could move this logic into a helper function, resulting in a code base that is more D.R.Y.!


@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")
Comment on lines +37 to +44

Choose a reason for hiding this comment

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

👍🏿


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

tasks_to_add = get_validated_tasks_from_request_body(request_body)
goal.tasks = tasks_to_add

db.session.commit()

response = {
"id": goal.id,
"task_ids": [task.id for task in tasks_to_add]
}

return jsonify(response), 200

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

tasks_response_list = []
for task in goal.tasks:

Choose a reason for hiding this comment

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

Nice work on leveraging the relationship between your two models. You are setting your relationship attribute, .tasks to the new list, tasks. This makes sure that the relationship is synced on both sides application and database levels.

tasks_response_list.append(task.to_dict()["task"])

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

return jsonify(response_body), 200



110 changes: 110 additions & 0 deletions app/routes/route_utilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from flask import abort, make_response, jsonify
from sqlalchemy import asc, desc
from app.models.task import Task
import requests
from ..db import db

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

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

if not model:
not_found = {"details": f"{cls.__name__} with id {model_id} was 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, to_dict_options=None):
query = db.select(cls)
order_by_clause = cls.id

if filters:
for parameter, value in filters.items():
if parameter == "sort":
if value == "asc":
order_by_clause = asc("title")
elif value == "desc":
order_by_clause = desc("title")
elif hasattr(cls, parameter):
query = query.where(getattr(cls,parameter).ilike(f"%{value}%"))

models = db.session.scalars(query.order_by(order_by_clause))


models_response = []
to_dict_options = to_dict_options if to_dict_options is not None else {}
for model in models:
model_dict = model.to_dict(**to_dict_options)
inner_key = cls.__name__.lower()

if inner_key in model_dict:
models_response.append(model_dict[inner_key])
else:
models_response.append(model_dict)

return models_response

def call_slackbot(task_title):
url = "https://hooks.slack.com/services/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"
text = f"Someone just completed the task: {task_title}"

try:
response = requests.post(url=url,json={"text": text},timeout=5.0)
response.raise_for_status()
except Exception:
response = f"An unexpected error occured."
abort(make_response(response,400))
Comment on lines +67 to +76

Choose a reason for hiding this comment

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

Nice work!




def get_validated_tasks_from_request_body(request_body):
try:
if not request_body:
response = {"details": "Request body is empty."}
abort(make_response(jsonify(response), 400))

task_ids_from_request = request_body["task_ids"]

if not isinstance(task_ids_from_request, list):
response = {"details": "'task_ids' must be a list."}
abort(make_response(jsonify(response), 400))

validated_task_objects = []
for task_id in task_ids_from_request:
if not isinstance(task_id, int):
response = {"details": f"Each task ID must be an integer, but received {task_id}."}
abort(make_response(jsonify(response), 400))

task_instance = validate_model(Task, task_id)
validated_task_objects.append(task_instance)

return validated_task_objects

except KeyError:
response = {"details": "Request body must contain 'task_ids'."}
abort(make_response(jsonify(response), 400))
except Exception as e:
response = {"details": f"An unexpected error occured: {e}."}
abort(make_response(jsonify(response), 400))


65 changes: 64 additions & 1 deletion app/routes/task_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,64 @@
from flask import Blueprint
from flask import Blueprint, Response, abort, make_response, request
import requests
from datetime import datetime
from app.models.task import Task
from .route_utilities import validate_model, create_model, get_models_with_filters, call_slackbot
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_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.to_dict()


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

for attribute, value in request_body.items():
if hasattr(Task, attribute):
setattr(task, attribute, value)

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

@bp.patch("<id>/<completion_status>")

Choose a reason for hiding this comment

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

Love that you are using route parameters in this way!

def modify_task_completion_status(id, completion_status):
task = validate_model(Task, id)

if completion_status == 'mark_incomplete':
task.completed_at = None

elif completion_status == 'mark_complete':
task.completed_at = datetime.now()
call_slackbot(task.title)
else:
response = {"details": f"Route {completion_status} not recognized"}
abort(make_response(response,400))

db.session.commit()

return Response(status=204, mimetype="application/json")
Comment on lines +52 to +64

Choose a reason for hiding this comment

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

Nice logic here! ⭐️

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