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

Sea Turtles - Christina W #100

Open
wants to merge 10 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
1 change: 1 addition & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: gunicorn 'app:create_app()'
40 changes: 40 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?
- post a message on a channel in slack
- What is the URL and HTTP method for this endpoint?
- https://slack.com/api/chat.postMessage?channel=<channel_name>&text=<message_text>&<otherargs>

Choose a reason for hiding this comment

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

Notice that the documentation for the endpoint lists POST as the method. So the endpoint itself would be only the https://slack.com/api/chat.postMessage, and we should be thinking about how to send the request data not as query parameters, but instead in the body of the request.

- What are the _two_ _required_ arguments for this endpoint?
- token, channel
- How does this endpoint relate to the Slackbot API key (token) we just created?
- I'm assuming we will use this endpoint to send messages about whether or not the tasks have been done

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

Expand All @@ -119,8 +123,42 @@ 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 got ok:True? And yes, we saw a message in our actual slack workspace
- Did we get an error emssage? If so, why?
- No...
- What is the shape of this JSON? Is it a JSON object or array? What keys and values are there?
- {"ok":<boolean>,
- "channel":<channel_string>,
- "ts": a string of numbers? Is this an IP?,
- "message"{
- "bot_id": string,
- "type": "message",
- "text": <textofmessage>,
- "user": string,
- "ts": same as before under ts?,
- "app_id": string of caps and nums,
- "team": another string of caps and nums,
- "bot_profile":{
- "id": same as bot_id,
- "app_id":same as app_id before,
- "name": <name_of_bot>,
- "icons": {
- "image_36": url,
- "image_48": url,
- "image_72": url
- },
- "deleted": boolean,
- "updated": a number,
- "team_id": same as team
- },
- "blocks":[
- {"type": "rich_text",
- "block_id": "PTh",
- "elements": [
- {"type":"rich_text_section",
- "elements":[
- {"type":"text",
- "text": text body}]}]}]}}

### Verify with Postman

Expand All @@ -139,6 +177,8 @@ Open Postman and make a request that mimics the API call to Slack that we just t
- In "Headers," add this new key-value pair:
- `Authorization`: `"Bearer xoxb-150..."`, where `xoxb-150...` is your full Slackbot token

--> DO NOT INCLUDE ANY QUOTES <----

![](assets/postman_test_headers.png)

Press "Send" and see the Slack message come through!
Expand Down
5 changes: 5 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,10 @@ def create_app(test_config=None):
migrate.init_app(app, db)

# Register Blueprints here
from app.routes.task_routes import task_bp

Choose a reason for hiding this comment

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

👍 Nice job splitting the routes into their own files (and remembering the __init__.py file).

app.register_blueprint(task_bp)

from app.routes.goal_routes import goal_bp
app.register_blueprint(goal_bp)

return app
42 changes: 42 additions & 0 deletions app/models/goal.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,46 @@


class Goal(db.Model):
__tablename__ = 'goals'
goal_id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String)
tasks = db.relationship("Task", backref="goals")

Choose a reason for hiding this comment

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

The value for the backref is literally the name that will be added to the model at the other end of this relationship. Since a Task belongs to only one Goal, goal (singular) might be a more suitable name.


def to_dict(self):
task_list = []
for task in self.tasks:
task_list.append(task.id)
if task_list:
return dict(
id=self.goal_id,
task_ids=task_list
)
return dict(
id=self.goal_id,
title=self.title)
Comment on lines +14 to +21

Choose a reason for hiding this comment

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

Nice thought to have the to_dict method pick between the output styles, similar to how a task decides whether to include goal information.

However, here I would be more inclined to have to different method that build different kinds of dictionaries. The Task to_dict ends up needing to display different tasks differently, from the same endpoint. But whether the task information is included for a goal is determined by which endpoint we are accessing (POST/GET)


def get_tasks(self):
from .task import Task

Choose a reason for hiding this comment

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

This import isn't necessary since we're not using the Task class name in this function.

task_list = []
for task in self.tasks:
readable_task = task.task_to_dict()
task_list.append(readable_task)
return dict(
id=self.goal_id,
title=self.title,
tasks=task_list)

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

def replace_details(self, data_dict):
if "task_ids" in data_dict:

Choose a reason for hiding this comment

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

Nice idea to bring both branches of this login into a helper, but like with the to_dict I would consider having this in two separate methods, one focusing on the model attributes, and one on the relationships.

This also might be an opportunity to create a separate type to handle that relationship logic, say like a UpdateGoalTasksOperation class. By having this method currently live directly on Goal, we wind up needing to import as you did to avoid a potential import cycle. If the logic lives in a third separate class, that class would just import both Goal and Task and there be no issue about who depends on whom.

from .task import Task
for id in data_dict["task_ids"]:
task = Task.query.get(id)
if task not in self.tasks:
self.tasks.append(task)
Comment on lines +43 to +44

Choose a reason for hiding this comment

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

Appending the task to the list of tasks for this goal will have the effect of adding the supplied tasks to the goal. If the intent of this operation was to set the tasks for the goal to be exactly what was passed in, we would need to take a slightly different approach. The tests for how this should behave are ambiguous, but think about how we might approach this if we wanted the list of tasks to replace the current tasks associated with this goal.

else:
self.title=data_dict["title"]
return self.to_dict()
61 changes: 60 additions & 1 deletion app/models/task.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,64 @@
import datetime
from app import db
from app.models.goal import Goal


class Task(db.Model):
task_id = db.Column(db.Integer, primary_key=True)
__tablename__ = 'tasks'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String)
description = db.Column(db.String)
Comment on lines +9 to +10

Choose a reason for hiding this comment

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

Consider whether we might want to prevent these from being nullable. Should we be able to create a task without a title? without a description?

is_complete = db.Column(db.Boolean, default=False)

Choose a reason for hiding this comment

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

Consider not making an actual is_complete column. We can calculate whether the task is complete or not based on the completed_at timestamp. If we store redundant data, we run the risk of the two pieces of data getting out of sync (say we update one but forget to update the other).

completed_at = db.Column(db.DateTime)
goal_id = db.Column(db.Integer, db.ForeignKey(Goal.goal_id))

def task_to_dict(self):
if self.goal_id:
return dict(
id=self.id,
title=self.title,
description=self.description,
is_complete=self.is_complete,

Choose a reason for hiding this comment

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

Instead of using the self.is_complete column, we can calculate the value we would need for the dictionary from the completed_at column. If completed_at is non-null, then the task is complete, otherwise it's not complete.

goal_id = self.goal_id
)
else:
return dict(
id=self.id,
title=self.title,
description=self.description,
is_complete=self.is_complete)
Comment on lines +26 to +29

Choose a reason for hiding this comment

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

Notice the only difference between this set of values and the one above is whether to include goal_id. We could build a dictionary that has all the keys in common first, and then only add in the goal_id key if this task belongs to a goal.


@classmethod
def from_dict(cls, data_dict):
if "completed_at" in data_dict:
return cls(
title=data_dict["title"],
description=data_dict["description"],
is_complete=True,
completed_at=data_dict["completed_at"]

Choose a reason for hiding this comment

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

Here's another spot where handling the completed data is the main difference between these two branches. Check out the .get() method on dictionaries, which will return the value of the key if present, or None (which would become null in the database).This would let us simplify the creation call.

)
else:
return cls(
title=data_dict["title"],
description=data_dict["description"]
)

def replace_details(self, data_dict):
self.title=data_dict["title"]
self.description=data_dict["description"]
self.is_complete=False
if "completed_at" in data_dict:
self.completed_at=data_dict["completed_at"]
self.is_complete=True
Comment on lines +50 to +52

Choose a reason for hiding this comment

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

👍 Nice helper to update the contents of the model, as well as checking for the presence of completed_at to allow it to be optional.

return self

def mark_done(self):
self.is_complete = True
current_time = datetime.datetime.now()
self.completed_at = current_time
return self

def mark_not_done(self):
self.is_complete = False
self.completed_at = None
return self
Comment on lines +55 to +64

Choose a reason for hiding this comment

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

👍 I love little helpers like this that are strictly speaking necessary, but still provide an abstraction of the operation we want to perform: setting or clearing the completed_at data.

1 change: 0 additions & 1 deletion app/routes.py

This file was deleted.

Empty file added app/routes/__init__.py
Empty file.
95 changes: 95 additions & 0 deletions app/routes/goal_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from flask import Blueprint, jsonify, make_response, request, abort
from app import db
from app.models.goal import Goal
from .task_routes import validate_task_id, Task

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

def error_message(message, status_code):
abort(make_response(jsonify(dict(details=message)), status_code))

def make_goal_safely(data_dict):
try:
return Goal.from_dict(data_dict)
except KeyError as err:
error_message(f"Invalid data", 400)

def update_goal_safely(goal, data_dict):
try:
return goal.replace_details(data_dict)
except KeyError as err:
error_message(f"Invalid data", 400)

def validate_goal_id(id):
try:
id = int(id)
except ValueError:
error_message(f"Invalid id {id}", 400)
goal = Goal.query.get(id)
if goal:
return goal
error_message(f"No goal with ID {id}. SORRY.", 404)
Comment on lines +8 to +31

Choose a reason for hiding this comment

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

These are really close to the similar functions in the task routes. Think about how we could pull them into their own helper file and what we would need to do to make them work for more than one class.


@goal_bp.route("", methods=["POST"])
def add_goal():
request_body = request.get_json()
new_goal=make_goal_safely(request_body)

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

task_response = {"goal":new_goal.to_dict()}

return jsonify(task_response), 201

@goal_bp.route("", methods=["GET"])
def get_goals():
goals = Goal.query.all()

result_list = [goal.to_dict() for goal in goals]
return jsonify(result_list)

@goal_bp.route("/<id>", methods=["GET"])
def get_goal_by_id(id):
goal = validate_goal_id(id)
result = {"goal": goal.to_dict()}
return jsonify(result)

@goal_bp.route("<id>", methods=["PUT"])
def update_goal(id):
goal = validate_goal_id(id)
request_body = request.get_json()
updated_goal = update_goal_safely(goal, request_body)

db.session.commit()

return jsonify({"goal":updated_goal})

@goal_bp.route("<id>", methods=["DELETE"])
def delete_goal(id):
goal = validate_goal_id(id)
db.session.delete(goal)
db.session.commit()

return jsonify({"details":f'Goal {id} "{goal.title}" successfully deleted'})

@goal_bp.route("/<id>/tasks", methods=["POST"])
def add_task_to_goal(id):
goal = validate_goal_id(id)
request_body = request.get_json()
data_dict = {"task_ids": []}
for task in request_body["task_ids"]:
validate_task_id(task)
data_dict["task_ids"].append(task)
Comment on lines +81 to +83

Choose a reason for hiding this comment

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

👍 Nice validation that all of the supplied task ids were valid.

Consider using the name task_id for the loop variable instead. I was initially confused since it looked like you were storing fully resolved tasks in a list keyed by task_ids.

If you are doing the resolving here, you could consider reworking your helper function to not use a dictionary. Simply pass in the list. Otherwise, consider moving all of the logic (including the task id validation) into a separate function, or even into Goal. But you would want to use a different approach to validating the tasks (since we wouldn't want to use validate_task_id in Goal as it would introduce a Flask dependency in our model class).

# updated_goal = update_goal_safely(goal, request_body)
update_tasks_in_goal = update_goal_safely(goal, data_dict)
db.session.commit()
# task_response = {"goal":update_tasks_in_goal}

return jsonify(update_tasks_in_goal), 200

@goal_bp.route("/<id>/tasks", methods=["GET"])
def get_tasks_from_goal(id):
goal = validate_goal_id(id)
result = goal.get_tasks()
return jsonify(result)
Loading