Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a7bff98
first commit project set up.
linakl19 May 1, 2025
ab1e16e
define Task model with its attributes.
linakl19 May 6, 2025
53e3252
generate migrations file
linakl19 May 6, 2025
8dd614d
creates Task Model with its helper methods.
linakl19 May 6, 2025
8a71103
implements route helper methods - Wave 1.
linakl19 May 6, 2025
a9ab640
Implements CRUD routes for Task model.
linakl19 May 6, 2025
59c898d
registers Task Blueprint
linakl19 May 6, 2025
dc54503
completes test_wave_01.
linakl19 May 6, 2025
66b0112
implements sonting tasks by title. Wave 2
linakl19 May 7, 2025
cbf6b29
completes tests wave 2
linakl19 May 7, 2025
5dd7e02
implements Task PATCH route to modify completed_at attr.
linakl19 May 7, 2025
e6fb97c
completes tests - Wave 3
linakl19 May 7, 2025
6d12bbe
calls Slack API - Wave 4
linakl19 May 7, 2025
fdf2185
modifies 'localhost' to '127.0.0.1' in task_list.py - CLI
linakl19 May 7, 2025
32f4cdb
refactor create_record() to create_model() in route_utilities.
linakl19 May 7, 2025
6e9ce52
implements update_model() as route helper function.
linakl19 May 8, 2025
57b6fe3
implements delete_model as a route helper function.
linakl19 May 8, 2025
6569f97
Create Goal model with instance method and class method.
linakl19 May 8, 2025
4c8e36c
Generate migration. Goal model added to schema
linakl19 May 8, 2025
9eb0475
Define CRUD routes for Goal Model.
linakl19 May 8, 2025
b8b0787
Register goal blueprint in create_app().
linakl19 May 8, 2025
1c3ccbe
Complete tests for Wave 5.
linakl19 May 8, 2025
a6bea76
Define goal_id FK in Taks model - one goal to many tasks relationship.
linakl19 May 8, 2025
860d4c3
Generate migration - adds goal_id as FK in task model
linakl19 May 8, 2025
615c005
Define Goal nested routes for one to many relationship
linakl19 May 8, 2025
6fdd934
Complete tests for Wave 6.
linakl19 May 8, 2025
fcabbad
Implement validate_multiple_models() as route helper function.
linakl19 May 9, 2025
6a8b803
Refactor assign_task_to_goal() - post nested route. Wave 6
linakl19 May 9, 2025
2bfe64c
Import type_checking for goal and task models.
linakl19 May 9, 2025
beb1fdf
Clean up code by removing comments and adjusting spacing.
linakl19 May 9, 2025
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
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 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.

👍

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(tasks_bp)
app.register_blueprint(goals_bp)

return app
29 changes: 28 additions & 1 deletion app/models/goal.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
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_dict = {
"id": self.id,
"title": self.title,
}

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

return goal_dict


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

Choose a reason for hiding this comment

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

Nitpick: spacing

Suggested change
new_goal= cls(title=goal_data["title"])
new_goal = cls(title=goal_data["title"])


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


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

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

Here's the SQLAlchemy documentation about nullability

image

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


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

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

return task_dict


@classmethod
def from_dict(cls, task_data):
new_task = cls(title=task_data["title"],
description=task_data["description"],
completed_at=task_data.get("completed_at", None)
)

return new_task
90 changes: 89 additions & 1 deletion app/routes/goal_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,89 @@
from flask import Blueprint
from flask import Blueprint, request
from app.models.goal import Goal
from app.models.task import Task
from app.routes.route_utilities import create_model, get_models_with_filters, validate_model, update_model, delete_model, validate_multiple_models
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("/<goal_id>")
def get_one_goal(goal_id):
goal = validate_model(Goal, goal_id)
return {"goal": goal.to_dict()}


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


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


# Nested Routes: One goal(parent) to many tasks(child)
@bp.get("/<goal_id>/tasks")
def get_tasks_by_goal(goal_id):
goal = validate_model(Goal, goal_id)

return dict(
id = goal.id,
title = goal.title,
tasks = [task.to_dict() for task in goal.tasks]
)
Comment on lines +46 to +50

Choose a reason for hiding this comment

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

Could we use the to_dict method you wrote in Goal?

Suggested change
return dict(
id = goal.id,
title = goal.title,
tasks = [task.to_dict() for task in goal.tasks]
)
return goal.to_dict()



@bp.post("/<goal_id>/tasks")
def assign_tasks_to_goal(goal_id):
"""
Associates the given task IDs with a goal by its ID, and unlinks any previously associated tasks not in the list.

Validates all task IDs and updates the database accordingly. Returns the goal ID and the updated list of task IDs.

Request: { "task_ids": [1, 2, 3] }
Response: { "id": 1, "task_ids": [1, 2, 3] }
"""

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

# Validate list taks ids in one query to db
valid_tasks = validate_multiple_models(Task, task_ids)

# Unlink tasks that are no longer assigned to this goal
for task_in_goal in goal.tasks:
if task_in_goal.id not in task_ids:
task_in_goal.goal_id = None

valid_tasks_ids = []

# Assign valid tasks to this goal
for task in valid_tasks:
valid_tasks_ids.append(task.id)
if task.goal_id != goal.id:
task.goal_id = goal.id
Comment on lines +72 to +82

Choose a reason for hiding this comment

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

What if we don't need to unlink the tasks because we can just replace them?

Suggested change
for task_in_goal in goal.tasks:
if task_in_goal.id not in task_ids:
task_in_goal.goal_id = None
valid_tasks_ids = []
# Assign valid tasks to this goal
for task in valid_tasks:
valid_tasks_ids.append(task.id)
if task.goal_id != goal.id:
task.goal_id = goal.id
tasks = [validate_model(task_id) for task_id in task_ids]
goal.tasks = tasks


db.session.commit()

return {
"id": goal.id,
"task_ids": valid_tasks_ids

Choose a reason for hiding this comment

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

valid_task_ids is a list of ids that we get from the client in the request body (see line 66). 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
"task_ids": valid_tasks_ids
"task_ids": [task.id for task in goal.tasks]

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


def validate_model(cls, model_id):
try:
model_id = int(model_id)
except:
response = {"message": f"{cls.__name__} id{model_id} is 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 as error:
response = {"details": "Invalid data"}

Choose a reason for hiding this comment

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

It might be nice to provide even more details in the response you send back so that the client knows how to fix up the request body they will re-send.

Suggested change
response = {"details": "Invalid data"}
response = {"details": f"Invalid data for field: {error.args[0]}"}

For example, this would evaluate to "title" for example if I sent a bad request body to create a Task.

abort(make_response(response, 400))

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

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


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

if filters:
for attribute, value in filters.items():
if attribute == "sort":
sort = value
continue

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

if sort == "asc":
query = query.order_by(cls.title.asc())
elif sort == "desc":
query = query.order_by(cls.title.desc())
else:
query = query.order_by(cls.id)

models = db.session.scalars(query)

return [model.to_dict() for model in models]


def update_model(model, model_data):
for attribute, value in model_data.items():
setattr(model, attribute, value)

db.session.commit()

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


def delete_model(model):
db.session.delete(model)
db.session.commit()

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


def validate_multiple_models(cls, id_list):

Choose a reason for hiding this comment

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

I see what you're going for here! I think this helper function works, but is unnecessary because we can use the validate_model method in a loop.

For example:

 for task_id in request_body['task_ids']:
    task = validate_model(Task, task_id)

"""
Validates that all IDs in the given list exist for the specified model class in one.

Parameters:
- cls: model class
- id_list: list of model ids

Returns:
- A list of model instances matching the given IDs.

Aborts:
- 400 if any ID is not a valid integer.
- 404 if one or more IDs are not found in the database.
"""

try:
id_list = [int(id) for id in id_list]
except:
response = {"message": f"One or more {cls.__name__} IDs are not valid integers" }
abort(make_response(response, 400))

# Query for all models matching the given IDs
models = db.session.scalars(db.select(cls).where(cls.id.in_(id_list))).all()

# Query for all models matching the given IDs
if len(models) != len(set(id_list)):
found_ids = {model.id for model in models}
missing_ids = set(id_list) - found_ids

response = {"message": f"{cls.__name__} IDs not found: {missing_ids}"}
abort(make_response(response, 404))

return models
85 changes: 84 additions & 1 deletion app/routes/task_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,84 @@
from flask import Blueprint
from datetime import datetime
import os
import requests # HTTP library to make a request to Slack API
from flask import Blueprint, Response, request
from app.models.task import Task
from app.routes.route_utilities import validate_model, get_models_with_filters, create_model, update_model, delete_model
from ..db import db


bp = Blueprint("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("/<task_id>")
def gets_one_task(task_id):
task = validate_model(Task, task_id)

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


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

return update_model(task, request_body)


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

return delete_model(task)


@bp.patch("/<task_id>/mark_complete")
def mark_task_complete(task_id):
"""
Marks a task as complete and sends a Slack notification.

If the task is not already completed, sets the `completed_at` timestamp and posts a message to Slack.
Returns a 204 No Content response.
"""
task = validate_model(Task, task_id)

path = "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.

This string is a constant value so we should name the variable with all capital letters.

Suggested change
path = "https://slack.com/api/chat.postMessage"
PATH = "https://slack.com/api/chat.postMessage"

API_KEY = os.environ.get("API_KEY")
headers = {"Authorization": f"Bearer {API_KEY}"}
body ={
"channel": "task-notifications",
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.

Nitpick: spacing

Also prefer the channel to be referenced by a constant variable too.

Suggested change
body ={
"channel": "task-notifications",
SLACK_CHANNEL = "task-notifications"
body = {
"channel": SLACK_CHANNEL,

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

if not task.completed_at:
task.completed_at = datetime.now()
slack_post = requests.post(path, headers=headers, json=body )
Comment on lines +57 to +67

Choose a reason for hiding this comment

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

Prefer all the logic related to calling Slack to be in a helper function (maybe like call_slack_api) to make this route more concise and single-responsibility.

Choose a reason for hiding this comment

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

Nitpick: spacing

Suggested change
slack_post = requests.post(path, headers=headers, json=body )
slack_post = requests.post(path, headers=headers, json=body)


db.session.commit()

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


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

if task.completed_at:
task.completed_at = None

db.session.commit()

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

2 changes: 1 addition & 1 deletion cli/task_list.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import requests

url = "http://localhost:5000"
url = "http://127.0.0.1:5000"

def parse_response(response):
if response.status_code >= 400:
Expand Down
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