-
Notifications
You must be signed in to change notification settings - Fork 15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Kayla Ecker - Accelerate #8
base: master
Are you sure you want to change the base?
Changes from all commits
392d011
42359ad
194935e
7c62955
9167b2b
b50729f
0aa26d4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
web: gunicorn 'app:create_app()' |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,4 +3,6 @@ | |
|
||
|
||
class Goal(db.Model): | ||
goal_id = db.Column(db.Integer, primary_key=True) | ||
id = db.Column(db.Integer, primary_key=True) | ||
title = db.Column(db.String) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we be able to create a goal with a NULL title? Consider adding |
||
tasks = db.relationship('Task', backref='goal', lazy=True) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 There are lots of interesting values that |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,4 +3,9 @@ | |
|
||
|
||
class Task(db.Model): | ||
task_id = db.Column(db.Integer, primary_key=True) | ||
id = db.Column(db.Integer, primary_key=True, autoincrement=True) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 I fully support this renaming! |
||
title = db.Column(db.String) | ||
description = db.Column(db.String) | ||
completed_at = db.Column(db.DateTime, nullable=True) | ||
Comment on lines
+7
to
+9
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should title or description be allowed to be NULL? (Does that make sense from a data standpoint?) Consider adding The way the project emphasized that completed_at needs to accept NULL values may make it seem like we needed to explicitly call out that nullable should be True, but it turns out this is the default for nullable. Instead, we should think about the other data in our model and consider whether it makes sense for any of it to be NULL. If not, we can have the database help up protect against that happening! |
||
|
||
goal_id = db.Column(db.Integer, db.ForeignKey('goal.id'), nullable=True) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,260 @@ | ||
from flask import Blueprint | ||
from flask import request, Blueprint, make_response, jsonify | ||
from app.models.task import Task | ||
from app import db | ||
from datetime import datetime | ||
import os | ||
import requests | ||
|
||
tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks") | ||
|
||
@tasks_bp.route("", strict_slashes=False, methods=["GET", "POST"]) | ||
def handle_tasks(): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The logic for GET and POST doesn't share any code, so we could consider putting the logic for each in separate functions, maybe |
||
if request.method == "GET": | ||
sort_method = request.args.get("sort") | ||
if sort_method == "asc": | ||
tasks = Task.query.order_by(Task.title.asc()).all() | ||
elif sort_method == "desc": | ||
tasks = Task.query.order_by(Task.title.desc()).all() | ||
else: | ||
tasks = Task.query.all() | ||
Comment on lines
+13
to
+19
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 Good job handling the sort param (pushing the sorting off for the DB to handle) and having a reasonable fallback behavior. |
||
|
||
tasks_response = [] | ||
for task in tasks: | ||
tasks_response.append( | ||
{ | ||
"id": task.id, | ||
"title": task.title, | ||
"description": task.description, | ||
"is_complete": task.completed_at or False | ||
Comment on lines
+25
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are many places in your routes where you build a dictionary like this (or very similar). Consider making a helper method, either here in the routes file (e.g. |
||
}) | ||
return jsonify(tasks_response) | ||
elif request.method == "POST": | ||
request_body = request.get_json() | ||
if 'title' not in request_body or 'description' not in request_body or 'completed_at' not in request_body: | ||
return make_response(jsonify({ "details": "Invalid data" }), 400) | ||
Comment on lines
+33
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 We should be doing similar checks when PUTting a task as well. So we could also think about moving checks like this into validation helpers so that they are easier to reuse elsewhere. We could even think about adding a class method to @classmethod
def from_dict(values):
# create a new task, and set the model values from the values passed in
# be sure to validate that all required values are present, we could return `None` or raise an error if needed
return new_task |
||
|
||
new_task = Task(title=request_body["title"], | ||
description=request_body["description"], | ||
completed_at=request_body["completed_at"], | ||
) | ||
|
||
if "completed_at" in request_body: | ||
new_task.completed_at = request_body["completed_at"] | ||
# set_task = True - Todo: Why couldn't I just set it here? Shouldn't need to set task below | ||
Comment on lines
+41
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You already know that What we'd really like is that |
||
|
||
set_task_status = True if new_task.completed_at else False | ||
|
||
db.session.add(new_task) | ||
db.session.commit() | ||
return make_response(jsonify({"task": { # todo - not really using jsonify? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
"id": new_task.id, | ||
"title": new_task.title, | ||
"description": new_task.description, | ||
"is_complete": set_task_status | ||
} | ||
}), 201) | ||
|
||
@tasks_bp.route("/<task_id>", methods=["GET", "PUT", "DELETE"]) | ||
def handle_tast(task_id): | ||
task = Task.query.get(task_id) | ||
if not task: | ||
return make_response(f"Invalid data", 404) | ||
Comment on lines
+59
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 We do need to do this check for GET, PUT, and DELETE requests, but we could still think about splitting these into separate functions (e.g. |
||
|
||
if request.method == "GET": | ||
|
||
get_one_task_response = { | ||
"task": { | ||
"id": task.id, | ||
"title": task.title, | ||
"description": task.description, | ||
"is_complete": task.completed_at or False | ||
} | ||
} | ||
if not task.goal_id: | ||
return get_one_task_response | ||
else: | ||
get_one_task_response["task"]["goal_id"] = task.goal_id | ||
Comment on lines
+73
to
+76
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 This works great, but we could simplify a little bit by checking for the need to add the goal_id and adding it, then returning both from the same place (at the end) rather than having the early return for the case where goal id isn't needed. if task.goal_id:
get_one_task_response["task"]["goal_id"] = task.goal_id This complication could also get moved into a possible |
||
return get_one_task_response | ||
|
||
elif request.method == "PUT": | ||
form_data = request.get_json() | ||
task.title = form_data["title"], | ||
task.description = form_data["description"] | ||
task.completed_at = form_data["completed_at"] | ||
Comment on lines
+81
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I mentioned this already above, but we should be sure that the same fields required for POSTing are included here for PUT. PUT replaces the value for the supplied task id, so we should ensure that all of the values required to represent a |
||
|
||
db.session.commit() | ||
|
||
set_task_status = True if task.completed_at else False | ||
|
||
return make_response(jsonify({"task": { | ||
"id": task.id, | ||
"title": task.title, | ||
"description": task.description, | ||
"is_complete": set_task_status | ||
}})) | ||
|
||
elif request.method == "DELETE": | ||
db.session.delete(task) | ||
db.session.commit() | ||
return jsonify({'details': f'Task {task_id} "{task.title}" successfully deleted'}) | ||
|
||
@tasks_bp.route("/<task_id>/mark_complete", strict_slashes=False, methods=["PATCH"]) | ||
def mark_complete(task_id): | ||
task = Task.query.get(task_id) | ||
if not task: | ||
return make_response(f"Task {task_id} not found", 404) | ||
|
||
task.completed_at = datetime.utcnow() | ||
db.session.commit() | ||
|
||
posts_message_to_slack(task) | ||
|
||
return make_response(jsonify({"task": { | ||
"id": task.id, | ||
"title": task.title, | ||
"description": task.description, | ||
"is_complete": True | ||
}})) | ||
|
||
@tasks_bp.route("/<task_id>/mark_incomplete", strict_slashes=False, methods=["PATCH"]) | ||
def mark_incomplete(task_id): | ||
task = Task.query.get(task_id) | ||
if not task: | ||
return make_response(f"Task {task_id} not found", 404) | ||
|
||
task.completed_at = None | ||
db.session.commit() | ||
|
||
return make_response(jsonify({ | ||
"task": { | ||
"id": task.id, | ||
"title": task.title, | ||
"description": task.description, | ||
"is_complete": False | ||
} | ||
})) | ||
|
||
|
||
def posts_message_to_slack(task): | ||
slack_path = "https://slack.com/api/chat.postMessage" | ||
slack_token = os.environ.get("SLACK_POST_MESSAGE_API_KEY") | ||
params = { | ||
"channel": "task-notifications", | ||
"text": f"Task completed: {task.title}", | ||
} | ||
headers = {"Authorization": f"Bearer {slack_token}"} | ||
requests.post(slack_path, params = params, headers = headers) | ||
Comment on lines
+138
to
+146
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 Nice helper method! Since we're sending a post request, we would typically send the parameters as form data, rather than as query params. Query params do have a maximum length (as part of the HTTP standard), so when we have potentially large data (like a text message), we often send that data in the form-encoded body of a POST request (this stems from older web standards. Now, we might use JSON in the request body). With the requests.post(slack_path, data=params, headers=headers) |
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# todo - separate out the route files | ||
from app.models.goal import Goal | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do see the comment that you would like to split this into a separate file, but since this is all together it would still be more expected to find all the imports at the top of the file. We had to import in strange places in |
||
|
||
goals_bp = Blueprint("goals", __name__, url_prefix="/goals") | ||
|
||
@goals_bp.route("", strict_slashes=False, methods=["GET", "POST"]) | ||
def handle_goals(): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar feedback about splitting these functions, and moving validation and dictionary-handling logic around that I made for |
||
if request.method == "GET": | ||
goals = Goal.query.all() | ||
goals_response = [] | ||
for goal in goals: | ||
goals_response.append({ | ||
"id": goal.id, | ||
"title": goal.title | ||
}) | ||
return jsonify(goals_response) | ||
elif request.method == "POST": | ||
request_body = request.get_json() | ||
if "title" not in request_body: | ||
return make_response({"details": "Invalid data"}, 400) | ||
new_goal = Goal(title=request_body["title"]) | ||
|
||
db.session.add(new_goal) | ||
db.session.commit() | ||
|
||
return make_response({"goal": | ||
{ | ||
"id": new_goal.id, | ||
"title": new_goal.title | ||
} | ||
}, 201) | ||
|
||
@goals_bp.route("/<goal_id>", strict_slashes=False, methods=["GET", "PUT", "DELETE"]) | ||
def handle_goal(goal_id): | ||
goal = Goal.query.get(goal_id) | ||
if not goal: | ||
return make_response(f"Goal {goal_id} not found", 404) | ||
|
||
if request.method == "GET": | ||
return { | ||
"goal": {"id": goal.id, | ||
"title": goal.title} | ||
} | ||
elif request.method == "PUT": | ||
form_data = request.get_json() | ||
goal.title = form_data["title"], | ||
|
||
db.session.commit() | ||
|
||
return make_response({ | ||
"goal":{ | ||
"id": goal.id, | ||
"title": goal.title | ||
} | ||
} | ||
) | ||
elif request.method == "DELETE": | ||
db.session.delete(goal) | ||
db.session.commit() | ||
return make_response( | ||
{"details": | ||
f"Goal {goal.id} \"{goal.title}\" successfully deleted" | ||
} | ||
) | ||
|
||
@goals_bp.route("/<goal_id>/tasks", strict_slashes=False, methods=["GET"]) | ||
def get_goal_tasks(goal_id): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 Nice job splitting these two routes into separate functions. |
||
goal = Goal.query.get(goal_id) | ||
if not goal: | ||
return make_response(f"Goal {goal_id} not found", 404) | ||
tasks = goal.tasks | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 Nice use of the helper relationship! |
||
tasks_details = [] | ||
for task in tasks: | ||
task_dict = { | ||
"id": task.id, | ||
"goal_id": goal.id, | ||
"title": task.title, | ||
"description": task.description, | ||
"is_complete": task.completed_at or False | ||
} | ||
tasks_details.append(task_dict) | ||
return make_response( | ||
{ | ||
"id": goal.id, | ||
"title": goal.title, | ||
"tasks": tasks_details | ||
}) | ||
|
||
@goals_bp.route("/<goal_id>/tasks", strict_slashes=False, methods=["POST"]) | ||
def add_goal_tasks(goal_id): | ||
goal = Goal.query.get(goal_id) | ||
if not goal: | ||
return make_response(f"Goal {goal_id} not found", 404) | ||
|
||
request_body = request.get_json() | ||
for id in request_body["task_ids"]: | ||
task = Task.query.get(id) | ||
goal.tasks.append(task) | ||
|
||
db.session.add(goal) | ||
db.session.commit() | ||
Comment on lines
+250
to
+254
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 We do need to retrieve each We can also wait to do the commit until after adding all the tasks. This will have the effect of committing this change all together in a single transaction, rather than running the risk of some of the tasks being added, and then possibly running into an error part of the way through (e.g. what if one of the task ids is invalid?). Also, what would happen if the goal previously had some tasks set. Do we want to add the new tasks to the existing tasks? Do we want to replace them and sever any prior task → goal relationships? What behavior is implemented here? |
||
|
||
return make_response( | ||
{ | ||
"id": goal.id, | ||
"task_ids": request_body["task_ids"] | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Generic single-database configuration. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice to see that your migrations appear to have survived intact over the course of the project. It seems like lots of folx needed to regenerate as an all-in-one migration at some point! One reminder would be to add a message when generating a migration ( |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
# 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 | ||
|
||
[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 | ||
|
||
[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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
One thing we started to touch on in the video store live code was that we can split routes into multiple files. We can make a routes folder, and put routes for each endpoint into separate files, named for their model. Then we can use the name
bp
for the blueprint in each file since it would be the only blueprint in the file. Then these imports might look like: