Skip to content
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

Sharks Kassidy Buslach task-list-api #112

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
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 ada-project-docs/wave_04.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,13 @@ Visit https://api.slack.com/methods/chat.postMessage to read about the Slack API
Answer the following questions. These questions will help you become familiar with the API, and make working with it easier.

- What is the responsibility of this endpoint?
- ---- Allows the bot to post on channels
- What is the URL and HTTP method for this endpoint?
- ---- http = POST "https://slack.com/api/chat.postMessage"
- What are the _two_ _required_ arguments for this endpoint?
- ---- Token, channel, (attachment,block, text)
- How does this endpoint relate to the Slackbot API key (token) we just created?
- ---- The token is required in the response of the request body in the JSON

Now, visit https://api.slack.com/methods/chat.postMessage/test.

Expand All @@ -119,8 +123,11 @@ Press the "Test Method" button!
Scroll down to see the HTTP response. Answer the following questions:

- Did we get a success message? If so, did we see the message in our actual Slack workspace?
- We see the message in slack but no success message.
- Did we get an error emssage? If so, why?
- I tested without text and we get an error message
- What is the shape of this JSON? Is it a JSON object or array? What keys and values are there?
It is a nested dictionary with some keys nested with dictionaries and some nested with lists.

### Verify with Postman

Expand Down
7 changes: 6 additions & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
migrate = Migrate()
load_dotenv()


def create_app(test_config=None):
app = Flask(__name__)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
Expand All @@ -28,7 +27,13 @@ def create_app(test_config=None):

db.init_app(app)
migrate.init_app(app, db)
from app.models.task import Task
from app.models.goal import Goal

# Register Blueprints here
from .routes import task_db
from .routes import goal_db
app.register_blueprint(task_db)
app.register_blueprint(goal_db)

return app
35 changes: 34 additions & 1 deletion app/models/goal.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,37 @@


class Goal(db.Model):
goal_id = db.Column(db.Integer, primary_key=True)
goal_id = db.Column(db.Integer, primary_key=True, autoincrement = True)
title = db.Column(db.String)

Choose a reason for hiding this comment

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

Consider adding nullable=False to ensure every goal requires a title.

tasks = db.relationship("Task", back_populates="goal", lazy = True)

def goal_to_json(self):
json_response = {
"id": self.goal_id,
"title": self.title
}
if self.tasks:
json_response["tasks"]= self.title
Comment on lines +14 to +15

Choose a reason for hiding this comment

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

It looks like you do a check here to see if tasks is a non empty value. Then you assign the value for json_response["tasks"] to self.title.

Should line 15 be something like this instead?

json_response["tasks"]= self.tasks


return json_response

def update_goal(self, request_body):
self.title = request_body["title"]


@classmethod
def create_goal(cls, request_body):
new_goal = cls(
title = request_body["title"])

return new_goal


def add_tasks(self, response_body):
if response_body["task_ids"]:
self.tasks = response_body["task_ids"]




# goal_id = goal_id,

Choose a reason for hiding this comment

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

Remember to remove commented out code to keep your files clean and to prevent a situation where code is accidentally uncommented and executes when it shouldn't.

39 changes: 39 additions & 0 deletions app/models/helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from .task import Task
from .goal import Goal
from flask import abort, make_response

def validate_id(task_id):

Choose a reason for hiding this comment

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

You could rename validate_id() to be more specific since you have a Task and a Goal model, something like validate_task_id() would work nicely (like your validate_goal_id())

try:
task_id = int(task_id)
except:
return abort(make_response({"message": f"Task {task_id} is not valid"}, 400))
task = Task.query.get(task_id)
if not task:
return abort(make_response({"message": f"Task {task_id} does not exist"}, 404))

return task


def validate_goal_id(goal_id):
try:
goal_id = int(goal_id)
except:
return abort(make_response({"message": f"Goal {goal_id} is not valid"}, 400))
goal = Goal.query.get(goal_id)
if not goal:
return abort(make_response({"message": f"Goal {goal_id} does not exist"}, 404))

return goal

def validate_data(request_body):

Choose a reason for hiding this comment

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

Nice helper function here to help handle errors if a request doesn't have all the necessary data. We can't assume that clients will always send us correct requests so this a great way to handle the error and send back some feedback to the client.

Consider renaming the method to be a little more descriptive like validate_task_request()

# return abort(make_response(f"Invalid data"),400)
if 'title' not in request_body:
return abort(make_response({"details":f"Invalid data"},400))
elif 'description' not in request_body:
return abort(make_response({"details":f"Invalid data"},400))
return request_body

def validate_goal(request_body):

Choose a reason for hiding this comment

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

👍 you could rename this to validate_goal_request to be extra explicit that we're validating a request (and not an instance of a goal object)

if 'title' not in request_body:
return abort(make_response({"details":f"Invalid data"},400))
return request_body
46 changes: 45 additions & 1 deletion app/models/task.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,49 @@
from app import db
from flask import make_response
from sqlalchemy import asc
from datetime import datetime
from app.models.goal import Goal


class Task(db.Model):
task_id = db.Column(db.Integer, primary_key=True)
task_id = db.Column(db.Integer, primary_key=True, autoincrement = True)
title = db.Column(db.String)

Choose a reason for hiding this comment

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

Consider adding nullable=False to ensure every task requires a title.

description = db.Column(db.String)
completed_at = db.Column(db.DateTime, nullable = True, default = None)
goal_id = db.Column(db.Integer, db.ForeignKey('goal.goal_id'))
goal = db.relationship("Goal", back_populates="tasks")

def to_json(self):
json_response = {
"id": self.task_id,
"title": self.title,
"description":self.description
}
if self.completed_at:
json_response["is_complete"] = True
else:
json_response["is_complete"] = False
Comment on lines +22 to +25

Choose a reason for hiding this comment

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

This could also be written with a ternary assignment like:

json_response["is_complete"] = True if self.completed_at else False

You can read more about the ternary operator here


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

return json_response

def update_task(self, request_body):
self.title = request_body["title"]
self.description = request_body["description"]
Comment on lines +33 to +34

Choose a reason for hiding this comment

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

request_body["title"] and request_body["description"] can throw KeyErrors if the request_body doesn't have these keys because the client sent a request without all the data. Consider calling your helper function validate_data() before line 33 to check that title and description were sent as part of the request and handle errors if they're not in the request.

if self.completed_at:
if self.completed_at == request_body['completed_at']:
Comment on lines +35 to +36

Choose a reason for hiding this comment

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

I think lines 35 and 36 can be combined into

if request_body['completed_at']:

since you want to check that completed_at exists in the request_body so you don't run into a KeyError

self.completed_at = datetime.utcnow()



@classmethod

Choose a reason for hiding this comment

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

👍 nice class method for creating a task. I like that you used the get() method on the request_body dictionary which doesn't throw an exception and allows you to pass in a default value!

def create_task(cls, request_body):

new_task = cls(title = request_body["title"],
description = request_body["description"],
completed_at = request_body.get("completed_at", None)
)

return new_task
176 changes: 175 additions & 1 deletion app/routes.py
Original file line number Diff line number Diff line change
@@ -1 +1,175 @@
from flask import Blueprint
from flask import Blueprint, jsonify, make_response, request, abort
from requests import session
from app.models.task import Task
from app.models.goal import Goal
from app.models.helper import validate_id, validate_data, validate_goal_id, validate_goal
from app import db
from sqlalchemy import asc, desc
from datetime import datetime
import requests
import os

task_db = Blueprint("tasks",__name__, url_prefix = "/tasks")
goal_db = Blueprint("goals", __name__, url_prefix = "/goals")
Comment on lines +12 to +13

Choose a reason for hiding this comment

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

Consider creating a directory called routes under your app directory and splitting your routes for Task and Goal into separate files (goal_routes.py and task_routes.py) so that you have 2 shorter files separated by class type.


# guard clause for invalid sort request
@task_db.route("", methods = ["GET"])
def get_all_tasks():
task_response = []
sort_query = request.args.get("sort")
if not sort_query:
ordered_tasks = Task.query.all()
Comment on lines +20 to +21

Choose a reason for hiding this comment

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

You could also putting this logic in an else block so your conditional statements would be like

if sort == 'asc':
        # do some stuff
    elif sort == 'desc':
        # do some other stuff
    else: 
        ordered_tasks = Task.query.all()

elif sort_query == "asc":
ordered_tasks = Task.query.order_by(asc(Task.title)).all()
elif sort_query == "desc":
ordered_tasks = Task.query.order_by(desc(Task.title)).all()
Comment on lines +22 to +25

Choose a reason for hiding this comment

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

with order_by() you don't need to use chain .all() to the end of these statements


for task in ordered_tasks:
task_response.append(task.to_json())
Comment on lines +27 to +28

Choose a reason for hiding this comment

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

This works well and is very readable, but if you'd like to incorporate list comprehensions into your code, you could write it like this:

task_response = [task.to_json() for task in ordered_tasks]

return jsonify(task_response), 200

@task_db.route("/<task_id>", methods = ["GET"])
def get_one_task(task_id):
task_response = []

Choose a reason for hiding this comment

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

Looks like task_response isn't being used and can be deleted

task = validate_id(task_id)
return jsonify({"task": task.to_json()}), 200

@task_db.route("", methods = ["POST"])
def create_one_response():
request_body = request.get_json()
valid_data = validate_data(request_body)
new_task = Task.create_task(valid_data)
db.session.add(new_task)
db.session.commit()
return jsonify({"task":new_task.to_json()}), 201


@task_db.route("/<task_id>", methods = ["PUT"])
def update_task(task_id):
task = validate_id(task_id)
request_body = request.get_json()
task.update_task(request_body)

Choose a reason for hiding this comment

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

You have error handling for the request that gets sent to the POST endpoint for /tasks. You also need to have error handling for a PUT request in case the client doesn't send a valid request.

I left a comment in your Task class for your update_task() method about adding error handling too. You could call validate_data() directly in the update_task() function.

Or you could call validate_data() before line 50 here liked you do above on line 40.


db.session.commit()
return jsonify({"task":task.to_json()}), 200

@task_db.route("/<task_id>", methods = ["DELETE"])

Choose a reason for hiding this comment

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

👍

def delete_task(task_id):
task = validate_id(task_id)
task_title = Task.query.get(task_id)
db.session.delete(task)
db.session.commit()
return {
"details": f'Task {task_id} \"{task_title.title}\" successfully deleted'}, 200

# @task_db.route("/<task_id>/mark_complete", methods = ["PATCH"])
# def mark_task_complete(task_id):
# task = validate_id(task_id)

# task.completed_at = datetime.now()

# db.session.commit()
# return jsonify({"task":task.to_json()}), 200
Comment on lines +65 to +72

Choose a reason for hiding this comment

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

Remove unused code to keep your files clean


@task_db.route("/<task_id>/mark_incomplete", methods = ["PATCH"])
def mark_task_incomplete(task_id):
task = validate_id(task_id)

task.completed_at = None

db.session.commit()
return jsonify({"task":task.to_json()}), 200

TOKEN = os.environ.get("SLACK_TOKEN")
SLACK_URL = os.environ.get("SLACK_URL")
Comment on lines +83 to +84

Choose a reason for hiding this comment

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

You can move these two constants into your mark_task_complete_by_slack_bot() method so they're not hanging around in the global scope (since no other function needs to use the constants)

@task_db.route("/<task_id>/mark_complete", methods = ["PATCH"])
def mark_task_complete_by_slack_bot(task_id):
task = validate_id(task_id)
validated_task = task.query.get(task_id)
task.completed_at = datetime.now()

headers = {"Authorization":f"Bearer {TOKEN}"}
data = {
"channel":"task-notifications",
"text": f"Someone just completed the task {task.title}."
}
res = requests.post(SLACK_URL, headers=headers, data=data)
Comment on lines +91 to +96

Choose a reason for hiding this comment

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

You can put this logic into a helper function and then call the function here. Doing so would help make your route a bit more concise


db.session.commit()
return jsonify({"task":task.to_json()}), 200


@goal_db.route("/<goal_id>", methods = ["GET"])
def get_one_goal(goal_id):
goal_response = []

Choose a reason for hiding this comment

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

You can delete goal_response since it's not being used in this method

goal = validate_goal_id(goal_id)
return jsonify({"goal": goal.goal_to_json()}), 200

@goal_db.route("", methods = ["GET"])
def get_all_goals():
goal_response = []
goals = Goal.query.all()
for goal in goals:
goal_response.append(goal.goal_to_json())

return jsonify(goal_response), 200

@goal_db.route("", methods = ["POST"])
def create_goals():
request_body = request.get_json()
is_valid = validate_goal(request_body)
new_goal = Goal.create_goal(is_valid)

db.session.add(new_goal)
db.session.commit()

return jsonify({"goal":new_goal.goal_to_json()}), 201

@goal_db.route("/<goal_id>", methods = ["DELETE"])
def delete_task(goal_id):
goal = validate_goal_id(goal_id)
goal_title = Goal.query.get(goal_id)
db.session.delete(goal_title)
db.session.commit()
return {
"details": f'Goal {goal_id} \"{goal_title.title}\" successfully deleted'}, 200

@goal_db.route("/<goal_id>", methods = ["PUT"])
def update_task(goal_id):
goal = validate_goal_id(goal_id)
request_body = request.get_json()
goal.update_goal(request_body)

Choose a reason for hiding this comment

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

Consider using your validate_goal() instance method before line 141 where you do the updating. Calling the validate_goal() from your helper file will raise an exception if the request_body doesn't have the key "title". Without adding in your validate_goal() helper function, update_goal() will raise a KeyError if "title" isn't in the request


db.session.commit()
return jsonify({"Goal":goal.goal_to_json()}), 200



@goal_db.route("/<goal_id>/tasks", methods = ["GET"])
def get_all_goals_and_tasks(goal_id):
valid_goal = validate_goal_id(goal_id)

task_response = []
for task in valid_goal.tasks:
task_response.append(task.to_json())


result = {"id": valid_goal.goal_id,
"title":valid_goal.title,
"tasks": task_response}
Comment on lines +157 to +159

Choose a reason for hiding this comment

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

You can call your instance method from the Goal class goal_to_json() on valid_goal instead of building up result.


return result, 200

Choose a reason for hiding this comment

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

Even though we know that this method returns a dict, and that automatically triggers jsonify to happen, I still like to call it explicitly to make it clear that this method will return a JSON response


@goal_db.route("/<goal_id>/tasks", methods = ["POST"])
def create_one_goal(goal_id):

Choose a reason for hiding this comment

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

In this POST request, we are sending an ID of a goal that already exists and also list of task IDs. Consider renaming this method to something like assign_tasks_to_goal() to be more descriptive.

valid_goal = validate_goal_id(goal_id)
request_body = request.get_json()


for task_id in request_body["task_ids"]:

Choose a reason for hiding this comment

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

You could add error handling here to make sure that the request_body has key "task_ids".

validate_id(task_id)
task = Task.query.get(task_id)

Choose a reason for hiding this comment

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

Your validate_id() helper function returns a task object. See lines 10 and 14 in your helper.py file.

Since that's the case, you can combine line 170 and 171 into:

task = validate_id(task_id)

valid_goal.tasks.append(task)
db.session.commit()

return {"id":valid_goal.goal_id,"task_ids":request_body["task_ids"]}, 200

Choose a reason for hiding this comment

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

Same comment as above about wrapping this dictionary in the jsonify() method to make it clear that this method will return a JSON response

1 change: 1 addition & 0 deletions migrations/README
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Generic single-database configuration.
Loading