-
Couldn't load subscription status.
- Fork 43
Bumblebees, Kate Marantidi #33
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
base: main
Are you sure you want to change the base?
Changes from all commits
aa982c4
9e01b9a
72e5c1d
24f685b
12736a1
1a999c7
d0be350
bf2f98e
8b136d1
0268b8d
f653f6b
faabd97
5f1ee70
b127d1e
005ed37
f8f537d
bb197c7
cce9649
e3c7a8a
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 |
|---|---|---|
| @@ -1,6 +1,9 @@ | ||
| .vscode | ||
| .DS_Store | ||
|
|
||
| # log files | ||
| app.log | ||
|
|
||
| # Byte-compiled / optimized / DLL files | ||
| __pycache__/ | ||
| *.py[cod] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| # Wave 6: Establishing a One-to-Many Relationship | ||
| # Wave 6: Establishing a One-to-Many Relationship | ||
|
|
||
| ## Goal | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,39 @@ | ||
| from sqlalchemy.orm import Mapped, mapped_column | ||
| from sqlalchemy.orm import Mapped, mapped_column, relationship | ||
| from ..db import db | ||
| from sqlalchemy import ForeignKey | ||
| from typing import Optional | ||
|
|
||
| class Goal(db.Model): | ||
|
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. 👍 Your Goal implementation is consistent with your Task model. Check the Task feedback for anything that could apply here. |
||
| id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) | ||
| title: Mapped[str] | ||
| tasks: Mapped[list["Task"]] = relationship(back_populates="goal") | ||
|
|
||
| @classmethod | ||
| def from_dict(cls, goal_data): | ||
| goal = cls(title=goal_data["title"]) | ||
|
|
||
| return goal | ||
|
|
||
| def goal_with_tasks(self): | ||
| task_list = [] | ||
|
|
||
| for task in self.tasks: | ||
| task = task.to_dict() | ||
| task["goal_id"] = self.id | ||
| task_list.append(task) | ||
|
|
||
| goal_with_tasks = self.to_dict() | ||
| goal_with_tasks["tasks"] = task_list | ||
|
|
||
| return goal_with_tasks | ||
|
|
||
| def to_dict(self): | ||
| goal_as_dict = { | ||
| "id": self.id, | ||
| "title": self.title, | ||
| } | ||
|
|
||
| # if self.tasks: | ||
| # goal_as_dict["task_ids"] = [task.id for task in self.tasks] | ||
|
Comment on lines
+36
to
+37
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 call removing this. In the project, the task data isn't included by default. The client must explcitly ask for it through an endpoint. It's a common consideration in an API how much variable length data to include per record by default, and often the static length data and variable length data will be split as we see in this project. |
||
|
|
||
| return goal_as_dict | ||
| 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, column_property, relationship | ||
| from sqlalchemy import ForeignKey | ||
| from typing import Optional | ||
| from sqlalchemy import DateTime | ||
| from datetime import datetime | ||
| from ..db import db | ||
|
|
||
| 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(DateTime, nullable=True) | ||
| is_complete = column_property(completed_at != None) | ||
| goal_id: Mapped[Optional[int]] = mapped_column(ForeignKey("goal.id")) | ||
| goal: Mapped[Optional["Goal"]] = relationship(back_populates="tasks") | ||
|
|
||
| @classmethod | ||
| def from_dict(cls, task_data): | ||
| goal_id=task_data.get("goal_id") | ||
| completed_at = task_data.get("completed_at", None) | ||
|
|
||
| return cls( | ||
| title=task_data["title"], | ||
| description=task_data["description"], | ||
|
Comment on lines
+23
to
+24
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. 👍 By reading title and description directly as keys we can trigger a |
||
| completed_at = completed_at, | ||
| goal_id=goal_id | ||
| ) | ||
|
|
||
|
|
||
| def to_dict(self, include_completed_at=False): | ||
|
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 idea to include an optional parameter allowing the inclusion of In larger applications where there may need to be many "rendered" forms of the record types, the problem of converting from model instances to a rendered form may be delegated to an entirely separate group of classes, whose sole responsibility is generating output formats from that model type. |
||
| task_as_dict = { | ||
|
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. 👍 Not including the outer task "envelope" wrapper in our |
||
| "id": self.id, | ||
| "title": self.title, | ||
| "description": self.description, | ||
| "is_complete": bool(self.is_complete) | ||
| } | ||
|
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. 🔎 Watch out that the end of the wrapped content doesn't extend before the indentation level we started at. It's still valid Python, but it's confusing to read jagged code. |
||
|
|
||
| if include_completed_at: | ||
| task_as_dict["completed_at"] = self.completed_at | ||
|
|
||
| if self.goal: | ||
| task_as_dict["goal_id"] = self.goal.id | ||
|
Comment on lines
+41
to
+42
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 logic to add the goal data to the task dictionary only when it's present. |
||
|
|
||
| return task_as_dict | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,93 @@ | ||
| 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 .route_utilities import validate_model, create_model, delete_model | ||
| from datetime import datetime | ||
| from datetime import timezone | ||
| import requests | ||
| import os | ||
|
|
||
| bp = Blueprint("goals_bp", __name__, url_prefix="/goals") | ||
|
|
||
|
|
||
| @bp.post("") | ||
| def create_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. 👍 Your Goal CRUD routes are consistent with your Task CRUD routes. Check the Task feedback for anything that could apply here. |
||
| request_body = request.get_json() | ||
| new_goal, status_code = create_model(Goal, request_body) | ||
|
|
||
| return {"goal": new_goal}, status_code | ||
|
|
||
|
|
||
| @bp.get("") | ||
| def get_all_goals(): | ||
| query = db.select(Goal) | ||
|
|
||
| title_param = request.args.get("title") | ||
| if title_param: | ||
| query = query.where(Goal.title.ilike(f"%{title_param}%")) | ||
|
|
||
| query = query.order_by(Goal.id) | ||
| result = db.session.execute(query) | ||
| goals = result.scalars().all() | ||
|
|
||
| goals_response = [] | ||
| for goal in goals: | ||
| goals_response.append(goal.to_dict()) | ||
|
|
||
| return goals_response | ||
|
|
||
|
|
||
| @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_one_goal(goal_id): | ||
| task = validate_model(Goal, goal_id) | ||
| request_body = request.get_json() | ||
|
|
||
| task.title = request_body["title"] | ||
|
|
||
| db.session.commit() | ||
|
|
||
| return Response(status=204, mimetype="application/json") | ||
|
|
||
|
|
||
| @bp.delete("/<goal_id>") | ||
| def delete_one_goal(goal_id): | ||
|
|
||
| return delete_model(Goal, goal_id) | ||
|
|
||
|
|
||
| @bp.post("/<goal_id>/tasks") | ||
| def tasks_to_goal(goal_id): | ||
| goal = validate_model(Goal, goal_id) | ||
|
|
||
| request_body = request.get_json() | ||
|
|
||
| if "task_ids" in request_body: | ||
| for task in goal.tasks: | ||
| task.goal_id = None | ||
|
|
||
| task_list = request_body.get("task_ids") | ||
|
|
||
| for task_id in task_list: | ||
| task = validate_model(Task, task_id) | ||
| task.goal_id = goal.id | ||
|
Comment on lines
+71
to
+79
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. Since we need to validate each Task id anyway, if we stored the resulting tasks in a list, the replacement of the tasks for the goal could be accomplished using the goal.tasks = taskswhich avoids the need to manually clear the goal association from the existing tasks. |
||
|
|
||
| db.session.commit() | ||
|
|
||
| return { | ||
| "id": goal.id, | ||
| "task_ids": task_list | ||
| } | ||
|
|
||
|
|
||
| @bp.get("/<goal_id>/tasks") | ||
| def tasks_for_specific_goal(goal_id): | ||
| goal = validate_model(Goal, goal_id) | ||
|
|
||
| return goal.goal_with_tasks(), 200 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| from flask import abort, make_response, Response | ||
| from ..db import db | ||
| import os | ||
| import requests | ||
| import logging | ||
|
|
||
|
|
||
| def validate_model(cls, model_id): | ||
| try: | ||
| model_id = int(model_id) | ||
| except: | ||
| response = {"message": f":{cls.__name__} with {model_id} 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__} with {model_id} does not exist"} | ||
| 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"} | ||
| # response = {"message": f"Invalid request: missing {error.args[0]}"} | ||
| abort(make_response(response, 400)) | ||
|
|
||
| db.session.add(new_model) | ||
| db.session.commit() | ||
|
|
||
| return new_model.to_dict(), 201 | ||
|
|
||
| def delete_model(cls, model_id): | ||
| model = validate_model(cls, model_id) | ||
|
|
||
| db.session.delete(model) | ||
| db.session.commit() | ||
|
|
||
| return Response(status=204, mimetype="application/json") | ||
|
|
||
|
|
||
| # Work in progress | ||
| def task_to_dict(Task, data): | ||
| { | ||
| "id": data.id, | ||
| "title": data.title, | ||
| "description": data.description, | ||
| "is_complete": bool(data.completed_at), | ||
| "goal": data.goal_id | ||
| } | ||
|
|
||
| return task_to_dict | ||
|
|
||
| def send_message_task_complete_slack(task_title): | ||
|
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 this logic extracted from the route. |
||
| slack_url = os.environ.get("SLACK_BOT_PATH") | ||
| slack_token = os.environ.get("SLACK_BOT_TOKEN") | ||
| slack_channel = os.environ.get("SLACK_CHANNEL_ID") # Can also be channel ID like "C01ABCXYZ" | ||
|
|
||
| message = { | ||
| "channel": slack_channel, | ||
| "text": f"Someone just completed the task {task_title}" | ||
| } | ||
|
|
||
| headers = { | ||
| "Authorization": f"Bearer {slack_token}", | ||
| "Content-Type": "application/json" | ||
| } | ||
|
|
||
| response = requests.post(slack_url, json=message, headers=headers) | ||
|
|
||
| if not response.ok: | ||
| logger = logging.getLogger(__name__) | ||
| logger.error(f"Slack message failed: {response.json()}") | ||
|
Comment on lines
+77
to
+78
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. 👍 Oh cool. You got the logger instance. |
||
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.
🔎 When adding files to a commit, be sure to check whether there are any unintentional changes that can be undone.