Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
afa229d
added post, and get functionality to task. Works on postman but not o…
MBsea21 Nov 1, 2024
a75034a
test_get_task, test_get_task_not_found passed.
MBsea21 Nov 1, 2024
9b81c64
test_create_task, test_update_task, test_update_task_not_found, test_…
MBsea21 Nov 1, 2024
f20ddf9
wave 1 tests all passed. Added 404, 400 error code functionality.
MBsea21 Nov 1, 2024
3fe589c
wave two tests passed
MBsea21 Nov 6, 2024
37ee6e6
wave 3 all tests passed
MBsea21 Nov 6, 2024
5dae0b9
added utilities folder which holds create_model, validate_model, delt…
MBsea21 Nov 8, 2024
0aca135
have gotten all tests up until the final two tests in wave 6 complete…
MBsea21 Nov 8, 2024
f80a1f2
wave six, all tests have succesfully passed, everything works so far!…
MBsea21 Nov 8, 2024
7c65e6b
Merge pull request #1 from MBsea21/madeline/backup-branch
MBsea21 Nov 8, 2024
92c6133
update with gunicorn
MBsea21 Nov 8, 2024
7e22320
updated slack-routes to be able to correctly call an env value
MBsea21 Nov 8, 2024
5de99b7
updated slack-token calling functionality
MBsea21 Nov 8, 2024
305c76e
another one-- working on fixing slack post functionality
MBsea21 Nov 8, 2024
34f623b
continued troubleshooting
MBsea21 Nov 8, 2024
9be98f8
fixed typo in os.getenv command
MBsea21 Nov 8, 2024
c327580
forgot to save last file, updated task_routes and saved this time
MBsea21 Nov 8, 2024
d2ad3aa
reworked utilities sorting function and updated the task get function…
MBsea21 Nov 8, 2024
8a8a0f5
updated slack api call to use requests
MBsea21 Nov 8, 2024
bba559b
trying to remove slack post blueprint
MBsea21 Nov 8, 2024
5dfb50c
removed excess http request, post to slack api is functional locally,…
MBsea21 Nov 9, 2024
08b6e37
removed erraneous imports
MBsea21 Nov 9, 2024
3affb57
made edits requested by Ansel in pull request #35
MBsea21 Feb 18, 2025
01179ea
Fixed additional typos
MBsea21 Feb 18, 2025
daf5dd9
fixed bugs, redid installfest so everything could run properly. all t…
MBsea21 Feb 19, 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
8 changes: 7 additions & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from flask import Flask
from flask import Flask, request, jsonify
from .db import db, migrate
from .models import task, goal

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Once we have other import paths to our models (here, through the blueprints, which import the models), we technically don't need the imports here any more. It's fine to leave them for clarity (and a reminder that any other models we add would need to be included until other routes are setup), but if the VS Code warning is bothersome, feel free to remove these.

from .routes.task_routes import tasks_bp
from .routes.goal_routes import goals_bp

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We would prefer to name the task_bp and goal_bp just bp in their respective route files, and then either do an import as here to differentiate them, or import each entire route module and access its Blueprint using dor notation.

import os

def create_app(config=None):
Expand All @@ -18,5 +20,9 @@ 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

41 changes: 40 additions & 1 deletion app/models/goal.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,44 @@
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from app.routes.utilities_routes import create_model, validate_model, check_for_completion
from typing import Optional
from sqlalchemy import ForeignKey
# from app.models.task import Task
from ..db import db

class Goal(db.Model):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str]
tasks: Mapped[list["Task"]] = relationship("Task",back_populates="goal")
# description=Mapped[str]
# completed_at: Mapped[Optional[datetime]]=mapped_column(nullable = True)


def to_dict(self):
goal_as_dict = {}
goal_as_dict["id"] = self.id
goal_as_dict["title"] = self.title
if self.tasks:
task_ids=[]
task_dictionaries = [task.to_dict() for task in self.tasks]
for task in task_dictionaries:
task_id = task.get("id")
task_ids.append(task_id)
goal_as_dict["task_ids"] = task_ids
else:
goal_as_dict["task_ids"] = []

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note that the cases that return task data are more related to the route than to whether the goal has tasks. There's a nested endpoint that the client is supposed to access if they want to retrieve the list of tasks for a goal. So rather than adding this conditional logic to the main to_dict method, we could make an additional dictionary conversion routine that uses the main to_dict to generate the scalar data, but then also adds the task data.

The distinction between adding goal details to tasks, but not task details to goal may seem arbitrary, and to a certain degree it is. When designing our own APIs, it's really up to us, and can be influenced largely by how many child records we expect there to typically be. If the main record (here, goal) had many properties, and often had many many tasks, we'd likely want to give the caller a way to retrieve the goal properties without getting all the task details at the same time, which could significantly increase the size of the response. On the other side, since a Task only ever has 1 goal, and even then we're only returning the goal id, conditionally adding that value doesn't add significantly to the overall size of the response.

So that's one justification for always including goal id data in the task response (if it has any), but not including task data in the goal response, unless specifically requested through the nested route.

# task_as_dict["description"] = self.description
# task_as_dict["is_complete"] = check_for_completion(Goal,self)

return goal_as_dict



@classmethod
def from_dict(cls, goal_data):
new_goal = cls(
title=goal_data["title"],
# description=goal_data["description"],
# completed_at=goal_data["completed_at"]
)
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 ..db import db
from datetime import datetime
from app.routes.utilities_routes import create_model, validate_model, check_for_completion

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👀 These imports aren't needed.

from typing import Optional
from sqlalchemy import ForeignKey

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Marking the completed_at column as Optional should be sufficient for this to be nullable. We shouldn't need the explicit mapped_column.

goal_id: Mapped[Optional[int]] = mapped_column(ForeignKey("goal.id"), nullable=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Here as well, since the column is Optional, we don't need the nullable option. We do need the mapped_column to establish the foreign key.

goal: Mapped["Goal"] = relationship("Goal", back_populates="tasks")

def to_dict(self):
task_as_dict = {}
task_as_dict["id"] = self.id
task_as_dict["title"] = self.title
task_as_dict["description"] = self.description
task_as_dict["is_complete"] = check_for_completion(Task,self)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I tend to prefer using a literal for the required values. There's less syntax involved, making it a little less redundant. Creating the dictionary, then setting the keys makes us write the task_as_dict[""] many times.

if self.goal_id:
task_as_dict["goal_id"] = self.goal_id
Comment on lines +24 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nice detection of goal membership to detemine whether to include the goal information.


return task_as_dict


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nice way to optionally allow a taks to be created as already belonging to a goal.


new_task = cls(
title=task_data["title"],
description=task_data["description"],
goal_id = goal_id
)
return new_task
96 changes: 95 additions & 1 deletion app/routes/goal_routes.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remember to always create a __init__.py in any new folders you create. It may appear that the imports are working correctly, but there are corner cases where this can become a problem. So the recommendation is to always make the __init__.py.

Original file line number Diff line number Diff line change
@@ -1 +1,95 @@
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 ..db import db
from datetime import datetime
from app.routes.utilities_routes import create_model, validate_model, get_models_with_filters, check_for_completion, delete_model

import requests

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remove unnecessary imports.


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


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

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

task_ids = request_body.get("task_ids", [])
for task_id in task_ids:
task = validate_model(Task, task_id)
if task:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

validate_model raises an error if the model can't be located, so this check isn't needed.

goal.tasks.append(task)
Comment thread
MBsea21 marked this conversation as resolved.
Outdated

db.session.commit()
goal = goal.to_dict()
response_body = {
"id": goal.get("id"),
"task_ids": goal.get("task_ids")
Comment thread
MBsea21 marked this conversation as resolved.
Outdated
}

return response_body, 200


####################################################################
######################### READ FUNCTIONS #########################
####################################################################
@goals_bp.get("")
def get_goals():
request_arguements = request.args
return get_models_with_filters(Goal, request_arguements)

@goals_bp.get("/<goal_id>")
def get_one_goal(goal_id):
goal = validate_model(Goal, goal_id)
response = {"goal": goal.to_dict()}
return make_response(response, 200)

@goals_bp.get("/<goal_id>/tasks")
def get_tasks_for_specific_goal(goal_id):
goal = validate_model(Goal, goal_id)
goal_as_dict = goal.to_dict()
tasks = [task.to_dict() for task in goal.tasks]

response_body = {
"id": goal_as_dict.get("id"),
"title": goal_as_dict.get('title'),
Comment thread
MBsea21 marked this conversation as resolved.
Outdated
"tasks": tasks
}
for key, value in goal_as_dict.items():
print("Key is : ", key),
print("Value is :", value)
Comment thread
MBsea21 marked this conversation as resolved.
Outdated

return response_body, 200

####################################################################
######################### UPDATE FUNCTIONS #########################
####################################################################
@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"]
Comment thread
MBsea21 marked this conversation as resolved.
Outdated
db.session.commit()

response_body = {"message": f"Goal #{goal_id} succesfully updated"}
return make_response(response_body, 200)


####################################################################
######################### DELETE FUNCTIONS #########################
####################################################################
@goals_bp.delete("/<goal_id>")
def delete_goal(goal_id):
goal = validate_model(Goal, goal_id)
return delete_model(Goal, goal)
34 changes: 34 additions & 0 deletions app/routes/slack_functions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from flask import request, jsonify, abort, make_response
import requests
import os


def send_message(message):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nice helper to contain the slack message logic.

slack_channel = os.environ.get("SLACK_CHANNEL")
slack_url = os.environ.get("SLACK_URL")

slack_api_token = os.getenv("SLACK_BOT_TOKEN")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Rather than having this read directly from the environment, consider having this function receive these values as input. This prevents the function from needing to "know" where to look for the conifguration values.

We could still add a wrapper function whose responsibility is to know where the configuration is, and then passes those values into this send_message function which would no longer need to know that.

payload = {
"channel": slack_channel,
"token": slack_api_token,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

According to the Slack documentation, we should prefer to send the api key in the header rather than in the body.

"text": message
}

response = requests.post(slack_url, data=payload)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note that using the data named parameter automatically sets the Content-Type header to be application/x-www-form-urlencoded, which is a supported type for the Slack API. To be able to send JSON-encoded data, we can use json named parameter instead.

message_sent = check_response(response)
if message_sent is True:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In application code, we rarely check directly against a boolean constant.
Here, prefer

    if message_sent:

return make_response({"message":"Slack message has been sent"}, 200)
else:
abort(make_response({"error":"something else broke, line 22 was met"}, 400))

def check_response(response):

if response.status_code == 200:
return True
else:
error_message = "could not post to slack, was unable to call slack api. Check slack url, token, and request body"
abort(make_response({"error": error_message},400))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We probably wouldn't want to abort the entire request solely from the Slack message failing. Perhaps we could log it, or take some other diagnostic action. For now, even ignoring failure entirely would be fine.

Also, be sure to check the documentation for how APIs behave. Slack sends a 200 response even for failures. This would really only detect whether there was some underlying network error when trying to reach Slack. But a problem with Slack itself wouldn't be detected.





98 changes: 97 additions & 1 deletion app/routes/task_routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,97 @@
from flask import Blueprint
from flask import Blueprint, abort, make_response, request, Response
from app.models.task import Task
from app.routes.utilities_routes import create_model, validate_model, get_models_with_filters, check_for_completion, delete_model
from app.routes.slack_functions import send_message
from datetime import datetime
from ..db import db
import requests

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We should remove unused imports (this may have been needed prior to some refactoring, but now there's no requests-dependent code here).

import os


tasks_bp = Blueprint("tasks_bp", __name__, url_prefix="/tasks")
invalid_data_response = ({"details" : "Invalid data"}, 400)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: no space between the key and the colon


#create a new task in database
@tasks_bp.post("")
def create_task():
request_body = request.get_json()
return create_model(Task,request_body)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: space after comma, here and throughout


@tasks_bp.get("")
def get_tasks():
request_arguements= request.args

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: misspelling in variable (should be arguments).

Suggest installing Code Spell Checker to help catch cases like this

return get_models_with_filters(Task,request_arguements), 200


#get task by task id:
@tasks_bp.get("/<task_id>")
def get_one_task(task_id):
task = validate_model(Task,task_id)
task_dict = task.to_dict()
response = {"task":task_dict}
expected = {
"task": {
"id": 1,
"title": "A Brand New Task",
"description": "Test Description",
"is_complete": False
}
}
print("the task dictionary is:\n", dict)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👀 Be sure to remove debugging code prior to submission.

print("the expected dictitonary was:\n",expected)
return response,200

#update task
@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"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These keys could be missing from the incoming request. This logic could benefit from checks similar to the KeyError handling for the POST routes.

try:
completed_at = request_body["completed_at"]
except:
completed_at=task.completed_at

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: add spaces around assignment


task.completed_at = completed_at
db.session.commit()

response = {"task":task.to_dict()}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: there should be a space after the colon

return response, 200


#Delete task
@tasks_bp.delete("/<task_id>")
def delete_task(task_id):
task = validate_model(Task,task_id)
return delete_model(Task, task)


#route 2
@tasks_bp.patch("/<task_id>/mark_complete")
def mark_complete(task_id):
task = validate_model(Task, task_id)
task.completed_at = datetime.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

datetime values are surpisingly difficult to work with correctly. This is why we only ask for a vlaue to be stored to mark completion, with the lack of a value indiciation incomplete. Especially for apps that might be storing information from remote users from around the world, a common approach for storing datetimes is to store timezone-aware data in the UTC timezone, then leave it to the local client to transform the value to the local timezone for display. This requires both using timezone-aware storage in the database, as well as timezone-aware Python calls. It's definitely worth looking into for future work!

message = f"Task {task.title} has been marked as complete!"
slack_response= send_message(message)

if slack_response.status_code == 200:
response = {"task":task.to_dict()}
db.session.commit()
return (response,200)
elif slack_response.status_code != 200:
return {"errror": "failed to send slack notification"}, 500

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

There's a lot of redundant error checking for this call. Here, and two versions in the utitliy functions.

As I wrote in the utilities, we would likely not want to halt the request solely due to a problem with Slack.





@tasks_bp.patch("/<task_id>/mark_incomplete")
def mark_incomplete(task_id):
task = validate_model(Task, task_id)
task.completed_at = None
db.session.commit()
response = {"task": task.to_dict()}
return make_response(response,200)


Loading