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,7 +1,12 @@
from flask import Flask
from .db import db, migrate
from .models import task, goal
from .routes.task_routes import tasks_bp
from .routes.goal_routes import 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.

Don't forget that Flask convention is to name your Blueprints bp and then inside of our __init__.py file we can import with as.

import os
from dotenv import load_dotenv
load_dotenv()
Comment on lines +7 to +8

Choose a reason for hiding this comment

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

Nice, using load_dotenv to ensure that your environmental variables are loaded before being accessed.



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
18 changes: 17 additions & 1 deletion app/models/goal.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..db import db


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.

Love this! This is the pattern that we showed you in class! Leveraging the Declarative Mapping Annotation to declare this column as a non-nullable string.

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.


@classmethod
def from_dict(cls, goal_data):
new_goal = cls(title=goal_data["title"])
return new_goal

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

return goal_as_dict
Comment on lines +10 to +21

Choose a reason for hiding this comment

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

👍🏿

43 changes: 42 additions & 1 deletion app/models/task.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,46 @@
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import ForeignKey
from datetime import datetime, UTC
from typing import Optional
from ..db import db


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")
Comment on lines +10 to +14

Choose a reason for hiding this comment

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

Perfect!


@classmethod
def from_dict(cls, task_data):
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 work, this lets you dynamically populate your foreign key attribute on your Task objects.

new_task = cls(
title=task_data["title"],
description=task_data["description"],
completed_at=datetime.utcnow() if task_data.get("is_complete") else None,

Choose a reason for hiding this comment

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

🫡

goal_id = goal_id
)

return new_task

def to_dict(self):
task_as_dict = {
"id": self.id,
"title": self.title,
"description": self.description,
"is_complete": bool(self.completed_at),

Choose a reason for hiding this comment

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

👍🏿

}

if self.goal_id:
task_as_dict["goal_id"] = self.goal_id

return task_as_dict


def mark_complete(self):
self.completed_at = datetime.now(UTC)

def mark_incomplete(self):
self.completed_at = None
Empty file added app/routes/__init__.py
Empty file.
86 changes: 85 additions & 1 deletion app/routes/goal_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,85 @@
from flask import Blueprint
from flask import Blueprint, abort, make_response, request, Response
from app.models.goal import Goal
from app.models.task import Task
from .route_utilities import validate_model, create_model
from ..db import db

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

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

Choose a reason for hiding this comment

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

Nice, using this helper function let's this function be more concise and modular—abstracting the logic of model creation into its own function.


@goals_bp.get("")
def get_all_goals():
query = db.select(Goal)

title_param = request.args.get("title")
if title_param:
query = query.where(Goal.name.ilike(f"%{title_param}%"))

goals = db.session.scalars(query)
goals_response = []
for goal in goals:
goals_response.append(goal.to_dict())

return goals_response
Comment on lines +16 to +27

Choose a reason for hiding this comment

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

We could D.R.Y. this up by using our get_models_with_filters helper function that we wrote in class.



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

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

Choose a reason for hiding this comment

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

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



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

goal.title = request_body["title"]
db.session.commit()

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


@goals_bp.delete("/<goal_id>")
def delete_goal(goal_id):
goal = validate_model(Goal, goal_id)
db.session.delete(goal)
db.session.commit()

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

Choose a reason for hiding this comment

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

👍🏿



@goals_bp.post("/<goal_id>/tasks")
def post_task_ids_to_goal(goal_id):
goal = validate_model(Goal, goal_id)

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

for task_id in task_ids:
task = validate_model(Task, task_id)
goal.tasks.append(task)
Comment on lines +63 to +67

Choose a reason for hiding this comment

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

I'd suggest validating the Task ids before setting goal.tasks = []. That way just in case you come across an invalid id, you'd abort the the logic before disassociating the pre-existing tasks.

Suggested change
goal.tasks = []
for task_id in task_ids:
task = validate_model(Task, task_id)
goal.tasks.append(task)
validated_tasks = []
for task_id in task_ids:
task = validate_model(Task, task_id)
validated_tasks.append(task)
goals.tasks = validated_tasks


db.session.commit()

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

Choose a reason for hiding this comment

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

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



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

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

response = {
"id": goal.id,
"title": goal.title,
"tasks": task_list
}
Comment on lines +80 to +84

Choose a reason for hiding this comment

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

A way to D.R.Y. up your implementations for converting your Goal object to a dictionary, with or without tasks is to add a flag, say with_tasks to your to_dict method. If that flag is True, then it could perform the logic to include a Goal's tasks. It could look something like this:

def to_dict(self, with_tasks=False):
    goal_dict = {
        self.id,
        self.title
    }
    
    if with_tasks:
        goal_dict["tasks"] = [task.to_dict() for task in self.tasks]
    
    return goal_dict

return response, 200
69 changes: 69 additions & 0 deletions app/routes/route_utilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from flask import abort, make_response
from ..db import db
import logging
import requests
import os
from dotenv import load_dotenv

load_dotenv()

Choose a reason for hiding this comment

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

Since you are calling this at the entry point of your application, __init__.py, you don't need this call here.

Suggested change
load_dotenv()



def validate_model(cls, model_id):
try:
model_id = int(model_id)
except:
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


logger = logging.getLogger(__name__)


def chat_post_message(task):
token = os.environ.get('SLACK_BOT_TOKEN')
channel = os.environ.get('SLACK_CHANNEL_ID')

try:
response = requests.post(
"https://slack.com/api/chat.postMessage",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
},

json={
"channel": channel,
"text": f"Someone just completed the task {task.title}"
}
)
if response.status_code == 200 and response.json().get("ok"):
logger.info("Slack message sent successfully.")
else:
logger.error(f"Slack API error: {response.json()}")
except Exception as e:
logger.error(f"Failed to send Slack message: {e}")
Comment on lines +31 to +53

Choose a reason for hiding this comment

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

Nice work on making this a helper function! It moves the logic out of the route function--Single Responsibility Principle!



def create_model(cls, model_data, response_key):

Choose a reason for hiding this comment

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

Notice that the only difference is we need to have either "task" or "goal" as the response key. This is just the name of the model that we are making an instance of in lowercase. We could use the .__name__ attribute to dynamically name this like so:

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

This change will allow us simply call the function and return the response that it builds us.

try:
new_model = cls.from_dict(model_data)

except KeyError:
response = {"details": "Invalid data"}

Choose a reason for hiding this comment

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

Suggested change
response = {"details": "Invalid data"}
response = { "details": "Invalid data" }

abort(make_response(response, 400))

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

return {response_key: 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.

Suggested change
return {response_key: new_model.to_dict()}, 201
return { response_key: new_model.to_dict() }, 201



86 changes: 85 additions & 1 deletion app/routes/task_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,85 @@
from flask import Blueprint
from flask import Blueprint, abort, make_response, request, Response
from app.models.task import Task
from .route_utilities import validate_model, chat_post_message, create_model
from sqlalchemy import asc, desc
from ..db import db

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

@tasks_bp.post("")
def create_task():
request_body = request.get_json()
return create_model(Task, request_body, "task")


@tasks_bp.get("")
def get_all_tasks():
query = db.select(Task)

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

sort_param = request.args.get("sort")
if sort_param:
if sort_param.lower() == "asc":
query = query.order_by(asc(Task.title))
elif sort_param.lower() == "desc":
query = query.order_by(desc(Task.title))

query = query.order_by(Task.id)

tasks = db.session.scalars(query)

tasks_response = []
for task in tasks:
tasks_response.append(task.to_dict())
return tasks_response
Comment on lines +19 to +37

Choose a reason for hiding this comment

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

We could D.R.Y. this up as well with get_model_with_filters



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

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

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()}
return { "task": task.to_dict() }



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

task.title = request_body["title"]
task.description = request_body["description"]
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.

This is another opportunity to D.R.Y. up our code! Notice how in this route and the PUT route for goal_routes.py we follow the pattern of:

object.ATTRIBUTE = request_body["ATTRIBUTE"]

We use hasattr and setattr to make a helper function to update our Task and Goal model. It would look like this:

def update_model(obj, data):
    for attr, value in data.items():
        if hasattr(obj, attr):
            setattr(obj, attr, value)
    
    db.session.commit()

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

This refactor not only makes our code D.R.Y but shows that we recognize logic that has higher level usability while handling cases of keys not being found!

db.session.commit()

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


@tasks_bp.delete("/<task_id>")
def delete_task(task_id):
task = validate_model(Task, task_id)
db.session.delete(task)
db.session.commit()

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


@tasks_bp.patch("/<task_id>/mark_complete")
def mark_task_complete(task_id):
task = validate_model(Task, task_id)
task.mark_complete()
db.session.commit()

chat_post_message(task)

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

@tasks_bp.patch("/<task_id>/mark_incomplete")
def mark_task_incomplete(task_id):
task = validate_model(Task, task_id)

task.mark_incomplete()
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